feat(prod): deploy web dashboard, migrate mongodb configuration, resolve server components render error and fix logo static asset paths
This commit is contained in:
1 parent
4882108068
commit
b2ea883601
119 files changed
+7123
-2011
No files matched your search
@@ -1,63 +1,77 @@
|
|||||||
// backend/db/capacityTracker.js
|
// backend/db/capacityTracker.js
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// MongoDB Capacity & Data Size Breakdown per Network Agent.
|
// 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');
|
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]') {
|
async function logCapacityStats(prefix = '[MongoDB]') {
|
||||||
try {
|
try {
|
||||||
if (!mongoose.connection || !mongoose.connection.db) {
|
if (!mongoose.connection || !mongoose.connection.db) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const db = mongoose.connection.db;
|
const db = mongoose.connection.db;
|
||||||
|
|
||||||
// 1. Fetch overall dbStats
|
// 1. Overall database stats (fast — reads WiredTiger metadata)
|
||||||
const stats = await db.command({ dbStats: 1 });
|
const stats = await db.command({ dbStats: 1 });
|
||||||
const dataSizeMB = (stats.dataSize / (1024 * 1024)).toFixed(2);
|
const dataSizeMB = (stats.dataSize / (1024 * 1024)).toFixed(2);
|
||||||
const storageSizeMB = (stats.storageSize / (1024 * 1024)).toFixed(2);
|
const storageMB = (stats.storageSize / (1024 * 1024)).toFixed(2);
|
||||||
console.log(`${prefix} Capacity Used: Data Size = ${dataSizeMB} MB, Storage Size = ${storageSizeMB} MB`);
|
console.log(`${prefix} Capacity Used: Data Size = ${dataSizeMB} MB, Storage Size = ${storageMB} MB`);
|
||||||
|
|
||||||
// 2. Fetch breakdown per Agent
|
// 2. Fast per-agent estimate: avgObjSize (from $collStats) × document count per agent
|
||||||
const agentSizes = {};
|
const agentBytes = {};
|
||||||
const collections = await db.listCollections().toArray();
|
const collections = await db.listCollections().toArray();
|
||||||
|
|
||||||
for (const colInfo of collections) {
|
for (const colInfo of collections) {
|
||||||
const colName = colInfo.name;
|
const colName = colInfo.name;
|
||||||
if (colName.startsWith('system.')) continue;
|
if (colName.startsWith('system.')) continue;
|
||||||
const col = db.collection(colName);
|
const col = db.collection(colName);
|
||||||
|
|
||||||
// Check if collection contains at least one document with an agent_uuid field
|
// Check collection has agent-tagged documents
|
||||||
const sampleDoc = await col.findOne({ agent_uuid: { $ne: null } });
|
const sampleDoc = await col.findOne({ agent_uuid: { $ne: null } }, { projection: { _id: 1 } });
|
||||||
if (!sampleDoc) continue;
|
if (!sampleDoc) continue;
|
||||||
|
|
||||||
const pipeline = [
|
// $collStats is O(1) — reads storage engine metadata, never scans documents
|
||||||
{ $project: { agent_uuid: 1, docSize: { $bsonSize: "$$ROOT" } } },
|
const collStatsArr = await col.aggregate([{ $collStats: { storageStats: {} } }]).toArray();
|
||||||
{ $group: { _id: "$agent_uuid", totalBytes: { $sum: "$docSize" } } }
|
const avgObjSize = collStatsArr[0]?.storageStats?.avgObjSize || 512; // bytes
|
||||||
];
|
|
||||||
|
// Count documents per agent using the existing agent_uuid index
|
||||||
const results = await col.aggregate(pipeline).toArray();
|
const countResult = await col.aggregate([
|
||||||
for (const res of results) {
|
{ $group: { _id: '$agent_uuid', count: { $sum: 1 } } }
|
||||||
const agent = res._id || 'Unknown';
|
]).toArray();
|
||||||
agentSizes[agent] = (agentSizes[agent] || 0) + res.totalBytes;
|
|
||||||
|
for (const r of countResult) {
|
||||||
|
const agent = r._id || 'Unknown';
|
||||||
|
agentBytes[agent] = (agentBytes[agent] || 0) + (r.count * avgObjSize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Format and log the breakdown
|
// 3. Format, log, and update cache
|
||||||
const sortedAgents = Object.entries(agentSizes)
|
const sorted = Object.entries(agentBytes)
|
||||||
.map(([agent, bytes]) => ({ agent, sizeMB: parseFloat((bytes / (1024 * 1024)).toFixed(2)) }))
|
.map(([agent, bytes]) => ({ agent, sizeMB: parseFloat((bytes / (1024 * 1024)).toFixed(2)) }))
|
||||||
.sort((a, b) => b.sizeMB - a.sizeMB);
|
.sort((a, b) => b.sizeMB - a.sizeMB);
|
||||||
|
|
||||||
if (sortedAgents.length > 0) {
|
if (sorted.length > 0) {
|
||||||
console.log(`${prefix} Data Size Breakdown per Agent:`);
|
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`);
|
console.log(` - ${agent}: ${sizeMB.toFixed(2)} MB`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agentSizesCache = {};
|
||||||
|
for (const { agent, sizeMB } of sorted) {
|
||||||
|
agentSizesCache[agent] = sizeMB;
|
||||||
|
}
|
||||||
|
lastCacheUpdate = new Date();
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`${prefix} Could not retrieve DB capacity breakdown:`, err.message);
|
console.warn(`${prefix} Could not retrieve DB capacity breakdown:`, err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { logCapacityStats };
|
module.exports = { logCapacityStats, agentSizesCache: () => agentSizesCache, lastCacheUpdate: () => lastCacheUpdate };
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ async function connectDB() {
|
|||||||
});
|
});
|
||||||
console.log('[MongoDB] ✓ Connected successfully');
|
console.log('[MongoDB] ✓ Connected successfully');
|
||||||
const { logCapacityStats } = require('./capacityTracker');
|
const { logCapacityStats } = require('./capacityTracker');
|
||||||
await logCapacityStats('[MongoDB]');
|
logCapacityStats('[MongoDB]').catch(err => console.warn('[MongoDB] Capacity log failed:', err.message));
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[MongoDB] ✗ Attempt ${attempt} failed: ${error.message}`);
|
console.error(`[MongoDB] ✗ Attempt ${attempt} failed: ${error.message}`);
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -135,52 +135,6 @@ const EventSchema = new mongoose.Schema({
|
|||||||
event_at: Date,
|
event_at: Date,
|
||||||
}, baseOptions);
|
}, 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 ─────────────────────────────
|
// ─── Compound Indexes for common dashboard queries ─────────────────────────────
|
||||||
SummarySchema.index({ agent_uuid: 1, timestamp: -1 });
|
SummarySchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
AppStatSchema.index({ agent_uuid: 1, timestamp: -1, download: -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 });
|
ThreatSchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
AppCategoryStatSchema.index({ agent_uuid: 1, timestamp: -1 });
|
AppCategoryStatSchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
EventSchema.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) ─────────────────
|
// ── Per-Device Per-Application Stats (synced from proxy) ─────────────────
|
||||||
const DeviceAppStatSchema = new mongoose.Schema({
|
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({ ip_address: 1, app_label: 1, timestamp: -1 });
|
||||||
DeviceAppStatSchema.index({ site_uuid: 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 telemetrySchemas = require('./SchemasTelemetry');
|
||||||
|
const auxSchemas = require('./SchemasAux');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
Summary: mongoose.model('Summary', SummarySchema),
|
Summary: mongoose.model('Summary', SummarySchema),
|
||||||
@@ -257,14 +176,9 @@ module.exports = {
|
|||||||
DeviceAppStat: mongoose.model('DeviceAppStat', DeviceAppStatSchema),
|
DeviceAppStat: mongoose.model('DeviceAppStat', DeviceAppStatSchema),
|
||||||
Flow: mongoose.model('Flow', FlowSchema),
|
Flow: mongoose.model('Flow', FlowSchema),
|
||||||
Threat: mongoose.model('Threat', ThreatSchema),
|
Threat: mongoose.model('Threat', ThreatSchema),
|
||||||
CustomDeviceLabel: mongoose.model('CustomDeviceLabel', CustomDeviceLabelSchema),
|
|
||||||
AppCategoryStat: mongoose.model('AppCategoryStat', AppCategoryStatSchema),
|
AppCategoryStat: mongoose.model('AppCategoryStat', AppCategoryStatSchema),
|
||||||
Event: mongoose.model('Event', EventSchema),
|
Event: mongoose.model('Event', EventSchema),
|
||||||
TlsVersionStat: mongoose.model('TlsVersionStat', TlsVersionStatSchema),
|
...auxSchemas,
|
||||||
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),
|
|
||||||
...telemetrySchemas
|
...telemetrySchemas
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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' },
|
role: { type: String, enum: ['SUPER_ADMIN', 'TENANT_ADMIN', 'SOC_ANALYST', 'ENGINEER', 'AGENT_VIEWER'], default: 'AGENT_VIEWER' },
|
||||||
site_uuid: { type: String, default: null, index: true },
|
site_uuid: { type: String, default: null, index: true },
|
||||||
agent_uuid: { type: String, default: null },
|
agent_uuid: { type: String, default: null },
|
||||||
|
created_by: { type: String, default: null, index: true },
|
||||||
is_active: { type: Boolean, default: true },
|
is_active: { type: Boolean, default: true },
|
||||||
}, {
|
}, {
|
||||||
timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' }
|
timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' }
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ module.exports = async function agentDetailsHandler(req, res, helpers) {
|
|||||||
getCustomLabelsMap
|
getCustomLabelsMap
|
||||||
} = helpers;
|
} = 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' });
|
if (!uuid) return res.status(400).json({ ok: false, message: 'uuid required' });
|
||||||
|
|
||||||
const timeFilter = getTimeFilter(req);
|
const timeFilter = getTimeFilter(req);
|
||||||
@@ -268,8 +271,14 @@ module.exports = async function agentDetailsHandler(req, res, helpers) {
|
|||||||
// 9. Server Discovery
|
// 9. Server Discovery
|
||||||
const server_discovery = [];
|
const server_discovery = [];
|
||||||
|
|
||||||
// Find the user object of this agent to get its name/label
|
const userQuery = { agent_uuid: uuid, role: 'AGENT_VIEWER' };
|
||||||
const agentUser = await User.findOne({ 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 agent_label = agentUser?.account_name || uuid;
|
||||||
|
|
||||||
const devicesDl = devices.reduce((sum, d) => sum + d.download, 0);
|
const devicesDl = devices.reduce((sum, d) => sum + d.download, 0);
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -10,169 +10,7 @@
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { AppStat, DeviceAppStat, Flow } = require('../models/Schemas');
|
const { AppStat, DeviceAppStat, Flow } = require('../models/Schemas');
|
||||||
|
|
||||||
// ─── Shared in-memory caches (for DPI API fallback only) ─────────────────────
|
const { getAppLookupCache, populateAppCache, fetchFromDpiApi } = require('./appDetailsDpiHelper');
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Handler ─────────────────────────────────────────────────────────────
|
// ─── Main Handler ─────────────────────────────────────────────────────────────
|
||||||
module.exports = async function appDetailsHandler(req, res, helpers) {
|
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 timeFilter = getTimeFilter(req);
|
||||||
const baseFilter = getBaseFilter(req, timeFilter);
|
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;
|
if (agentUuid) baseFilter.agent_uuid = agentUuid;
|
||||||
|
|
||||||
// ── Step 1: Query DeviceAppStat (Primary source for per-device bandwidth per-app) ──
|
// ── 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) {
|
if (token && SITE_UUID) {
|
||||||
await populateAppCache(BASE_URL, token, SITE_UUID).catch(e => console.warn('[AppDetails] Cache error:', e.message));
|
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 = {};
|
const ipsMap = {};
|
||||||
deviceApps.forEach(da => {
|
deviceApps.forEach(da => {
|
||||||
|
|||||||
+114
-9
@@ -5,9 +5,11 @@ const jwt = require('jsonwebtoken');
|
|||||||
const User = require('../../models/User');
|
const User = require('../../models/User');
|
||||||
const { makeToken, setCookieToken, requireAuth, JWT_SECRET } = require('./helpers');
|
const { makeToken, setCookieToken, requireAuth, JWT_SECRET } = require('./helpers');
|
||||||
|
|
||||||
|
const { TenantConfig, CustomAgentLocation } = require('../../models/Schemas');
|
||||||
|
|
||||||
const router = express.Router();
|
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 () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const count = await User.countDocuments({ role: 'SUPER_ADMIN' });
|
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!');
|
console.log('[Auth] ⚠ GANTI PASSWORD INI SEGERA DI PRODUCTION!');
|
||||||
}
|
}
|
||||||
|
|
||||||
const analystCount = await User.countDocuments({ role: 'SOC_ANALYST' });
|
const siabCount = await User.countDocuments({ username: 'siab' });
|
||||||
if (analystCount === 0) {
|
if (siabCount === 0) {
|
||||||
const hash = bcrypt.hashSync('analyst', 10);
|
const hash = bcrypt.hashSync('siab', 10);
|
||||||
await User.create({
|
await User.create({
|
||||||
username: 'analyst',
|
username: 'siab',
|
||||||
password_hash: hash,
|
password_hash: hash,
|
||||||
account_name: 'BackOne SOC Analyst',
|
account_name: 'SIAB Administrator',
|
||||||
role: 'SOC_ANALYST',
|
role: 'TENANT_ADMIN',
|
||||||
site_uuid: process.env.NETIFY_SITE_UUID || null,
|
site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e',
|
||||||
agent_uuid: null,
|
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) {
|
} catch (err) {
|
||||||
console.warn('[Auth] Seed skipped (MongoDB not ready yet):', err.message);
|
console.warn('[Auth] Seed skipped (MongoDB not ready yet):', err.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const multer = require('multer');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
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) {
|
function makeToken(user) {
|
||||||
return jwt.sign(
|
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() {
|
function getUploadsDir() {
|
||||||
if (fs.existsSync('/home/adminbackend/web/demoplace.my.id/public_html')) {
|
if (fs.existsSync('/home/adminbackend/web/demoplace.my.id/public_html')) {
|
||||||
return '/home/adminbackend/web/demoplace.my.id/public_html/api/uploads';
|
return '/home/adminbackend/web/demoplace.my.id/public_html/api/uploads';
|
||||||
|
|||||||
@@ -17,7 +17,17 @@ function blockAnalyst(req, res, next) {
|
|||||||
// GET /api/auth/admin/users — daftar semua users (admin & analyst)
|
// GET /api/auth/admin/users — daftar semua users (admin & analyst)
|
||||||
router.get('/admin/users', requireAdmin, async (req, res) => {
|
router.get('/admin/users', requireAdmin, async (req, res) => {
|
||||||
try {
|
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 => ({
|
const data = users.map(u => ({
|
||||||
id: u._id.toString(),
|
id: u._id.toString(),
|
||||||
username: u.username,
|
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' });
|
return res.status(400).json({ ok: false, error: 'Username dan password wajib diisi' });
|
||||||
}
|
}
|
||||||
const passwordHash = bcrypt.hashSync(password, 10);
|
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({
|
const newUser = await User.create({
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
@@ -51,6 +75,7 @@ router.post('/admin/create-agent-user', requireAdmin, blockAnalyst, async (req,
|
|||||||
agent_uuid: agent_uuid?.trim() || null,
|
agent_uuid: agent_uuid?.trim() || null,
|
||||||
role: 'AGENT_VIEWER',
|
role: 'AGENT_VIEWER',
|
||||||
site_uuid: siteUuid,
|
site_uuid: siteUuid,
|
||||||
|
created_by: req.adminUser.username,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ ok: true, message: 'Akun Network Agent berhasil dibuat', userId: newUser._id.toString() });
|
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');
|
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) 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 (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()) {
|
if (username?.trim()) {
|
||||||
const existing = await User.findOne({ username: username.trim(), _id: { $ne: user_id } });
|
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 (password) target.password_hash = bcrypt.hashSync(password, 10);
|
||||||
if (account_name != null) target.account_name = account_name?.trim() || null;
|
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;
|
if (req.file) target.profile_picture = req.file.filename;
|
||||||
|
|
||||||
await target.save();
|
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);
|
const target = await User.findById(req.params.id);
|
||||||
if (!target) return res.status(404).json({ ok: false, error: 'User tidak ditemukan' });
|
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 (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);
|
await User.findByIdAndDelete(req.params.id);
|
||||||
res.json({ ok: true, message: 'Akun berhasil dihapus' });
|
res.json({ ok: true, message: 'Akun berhasil dihapus' });
|
||||||
} catch (err) {
|
} 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' });
|
if (!req.file) return res.status(400).json({ ok: false, error: 'File gambar wajib diupload' });
|
||||||
const target = await User.findById(req.params.id);
|
const target = await User.findById(req.params.id);
|
||||||
if (!target) return res.status(404).json({ ok: false, error: 'User tidak ditemukan' });
|
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;
|
target.profile_picture = req.file.filename;
|
||||||
await target.save();
|
await target.save();
|
||||||
res.json({ ok: true, message: 'Foto profil berhasil diperbarui', filename: req.file.filename });
|
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 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({
|
const newUser = await User.create({
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
@@ -143,6 +195,7 @@ router.post('/admin/create-external-user', requireAdmin, blockAnalyst, upload.si
|
|||||||
account_name: account_name?.trim() || null,
|
account_name: account_name?.trim() || null,
|
||||||
role: role,
|
role: role,
|
||||||
site_uuid: siteUuid,
|
site_uuid: siteUuid,
|
||||||
|
created_by: createdBy,
|
||||||
profile_picture: req.file ? req.file.filename : null
|
profile_picture: req.file ? req.file.filename : null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,20 +10,55 @@ const axios = require('axios');
|
|||||||
|
|
||||||
const PROXY_URL = process.env.PROXY_URL || 'http://localhost:4000';
|
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 ────────────────────────────────────────────────────
|
// ─── Rebranding Middleware ────────────────────────────────────────────────────
|
||||||
router.use((req, res, next) => {
|
router.use((req, res, next) => {
|
||||||
const originalJson = res.json.bind(res);
|
const originalJson = res.json.bind(res);
|
||||||
res.json = function (body) {
|
res.json = function (body) {
|
||||||
if (body) {
|
if (body) {
|
||||||
try {
|
try {
|
||||||
const sanitized = JSON.stringify(body)
|
body = rebrandObj(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);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Dashboard] Rebrand error:', err.message);
|
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/telemetry'));
|
||||||
router.use(require('./dashboard/events'));
|
router.use(require('./dashboard/events'));
|
||||||
router.use(require('./dashboard/sslSan'));
|
router.use(require('./dashboard/sslSan'));
|
||||||
|
router.use(require('./dashboard/tenantConfig'));
|
||||||
|
router.use(require('./dashboard/agentLocations'));
|
||||||
|
router.use(require('./dashboard/blacklist'));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -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;
|
||||||
@@ -53,10 +53,20 @@ router.get('/agents/uptime', async (req, res) => {
|
|||||||
// GET /api/dashboard/agents
|
// GET /api/dashboard/agents
|
||||||
router.get('/agents', async (req, res) => {
|
router.get('/agents', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.user?.role !== 'SUPER_ADMIN' && req.user?._originalRole !== 'SUPER_ADMIN') {
|
const isAuthorized = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
return res.status(403).json({ ok: false, error: 'Forbidden: SUPER_ADMIN only' });
|
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 });
|
res.json({ ok: true, count: agents.length, agents });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ ok: false, error: err.message });
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
@@ -64,49 +74,29 @@ router.get('/agents', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/dashboard/agents/storage
|
// 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) => {
|
router.get('/agents/storage', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.user?.role !== 'SUPER_ADMIN' && req.user?._originalRole !== 'SUPER_ADMIN') {
|
const isAuthorized = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
return res.status(403).json({ ok: false, error: 'Forbidden: SUPER_ADMIN only' });
|
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;
|
const { agentSizesCache, lastCacheUpdate } = require('../../db/capacityTracker');
|
||||||
if (!db) {
|
const storage = agentSizesCache();
|
||||||
return res.json({ ok: true, storage: {} });
|
const cachedAt = lastCacheUpdate();
|
||||||
}
|
|
||||||
|
|
||||||
const agentSizes = {};
|
res.json({ ok: true, storage, cached_at: cachedAt });
|
||||||
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 });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ ok: false, error: err.message });
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -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;
|
||||||
@@ -61,10 +61,33 @@ router.get('/devices', async (req, res) => {
|
|||||||
// POST /api/dashboard/devices/update-label
|
// POST /api/dashboard/devices/update-label
|
||||||
router.post('/devices/update-label', async (req, res) => {
|
router.post('/devices/update-label', async (req, res) => {
|
||||||
try {
|
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;
|
const { mac_address, device_label } = req.body;
|
||||||
if (!mac_address) return res.status(400).json({ ok: false, error: 'mac_address required' });
|
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' });
|
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(
|
await CustomDeviceLabel.findOneAndUpdate(
|
||||||
{ mac_address },
|
{ mac_address },
|
||||||
{ device_label },
|
{ device_label },
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ const { getTimeFilter, getBaseFilter } = require('./helpers');
|
|||||||
// GET /api/dashboard/events
|
// GET /api/dashboard/events
|
||||||
router.get('/events', async (req, res) => {
|
router.get('/events', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
console.log('[/events] Request received. Query:', req.query);
|
|
||||||
const limit = parseInt(req.query.limit ?? 0);
|
const limit = parseInt(req.query.limit ?? 0);
|
||||||
const timeFilter = getTimeFilter(req);
|
const timeFilter = getTimeFilter(req);
|
||||||
const base = getBaseFilter(req, timeFilter);
|
const base = getBaseFilter(req, timeFilter);
|
||||||
console.log('[/events] Event base filter:', base);
|
|
||||||
|
|
||||||
let query = Event.find(base).sort({ timestamp: -1 });
|
let query = Event.find(base).sort({ timestamp: -1 });
|
||||||
if (limit > 0) {
|
if (limit > 0) {
|
||||||
@@ -26,13 +24,10 @@ router.get('/events', async (req, res) => {
|
|||||||
let macToIpMap = {};
|
let macToIpMap = {};
|
||||||
if (missingIpMacs.length > 0) {
|
if (missingIpMacs.length > 0) {
|
||||||
const baseFilterNull = getBaseFilter(req, null);
|
const baseFilterNull = getBaseFilter(req, null);
|
||||||
console.log('[/events] getBaseFilter(req, null) returned:', baseFilterNull);
|
|
||||||
|
|
||||||
const filterForDevices = {
|
const filterForDevices = {
|
||||||
mac_address: { $in: missingIpMacs },
|
mac_address: { $in: missingIpMacs },
|
||||||
...baseFilterNull
|
...baseFilterNull
|
||||||
};
|
};
|
||||||
console.log('[/events] DEBUG filterForDevices:', filterForDevices);
|
|
||||||
const devices = await DeviceStat.find(filterForDevices).lean();
|
const devices = await DeviceStat.find(filterForDevices).lean();
|
||||||
for (const d of devices) {
|
for (const d of devices) {
|
||||||
macToIpMap[d.mac_address] = d.ip_address;
|
macToIpMap[d.mac_address] = d.ip_address;
|
||||||
|
|||||||
@@ -6,18 +6,36 @@ const { getTimeFilter, getBaseFilter, topFlowField } = require('./helpers');
|
|||||||
// GET /api/dashboard/flows
|
// GET /api/dashboard/flows
|
||||||
router.get('/flows', async (req, res) => {
|
router.get('/flows', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const limit = parseInt(req.query.limit ?? 50);
|
const rawLimit = parseInt(req.query.limit ?? 50);
|
||||||
const skip = parseInt(req.query.skip ?? 0);
|
const skip = parseInt(req.query.skip ?? 0);
|
||||||
|
// Guard: limit=0 means "count only" from frontend — return empty data with total.
|
||||||
|
// Cap at 20000 per Rule 14 to prevent server memory overload.
|
||||||
|
const limit = rawLimit <= 0 ? 0 : Math.min(rawLimit, 20000);
|
||||||
const timeFilter = getTimeFilter(req);
|
const timeFilter = getTimeFilter(req);
|
||||||
const query = getBaseFilter(req, timeFilter);
|
const query = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
if (limit === 0) {
|
||||||
|
// 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
|
const raw = await Flow
|
||||||
.find(query)
|
.find(query)
|
||||||
.sort({ timestamp: -1 })
|
.sort({ timestamp: sortOrder })
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const data = raw.map(f => {
|
const data = raw.map(f => {
|
||||||
const port = f.dst_port ?? 0;
|
const port = f.dst_port ?? 0;
|
||||||
const proto = f.protocol || 'TCP';
|
const proto = f.protocol || 'TCP';
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
const { CustomDeviceLabel, Flow } = require('../../models/Schemas');
|
const { CustomDeviceLabel, Flow } = require('../../models/Schemas');
|
||||||
|
|
||||||
function getTimeFilter(req) {
|
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';
|
const range = req.query.timeRange || '1d';
|
||||||
if (range === 'all') return null;
|
if (range === 'all') return null;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const ms = {
|
const ms = {
|
||||||
'5m': 5 * 60000,
|
'5m': 5 * 60000,
|
||||||
'30m': 30 * 60000,
|
'30m': 30 * 60000,
|
||||||
'1h': 60 * 60000,
|
'1h': 60 * 3600000,
|
||||||
'1d': 24 * 3600000,
|
'1d': 24 * 3600000,
|
||||||
'7d': 7 * 24 * 3600000,
|
'7d': 7 * 24 * 3600000,
|
||||||
};
|
};
|
||||||
@@ -15,16 +28,17 @@ function getTimeFilter(req) {
|
|||||||
return { $gte: new Date(now.getTime() - delta) };
|
return { $gte: new Date(now.getTime() - delta) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function getBaseFilter(req, timeFilter = null) {
|
function getBaseFilter(req, timeFilter = null) {
|
||||||
const filter = {};
|
const filter = {};
|
||||||
if (timeFilter) filter.timestamp = timeFilter;
|
if (timeFilter) filter.timestamp = timeFilter;
|
||||||
|
|
||||||
const requestedSiteUuid = req.headers['x-backone-site-uuid'];
|
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;
|
filter.site_uuid = requestedSiteUuid;
|
||||||
} else if (req.user?.site_uuid) {
|
} else if (req.user?.site_uuid) {
|
||||||
filter.site_uuid = req.user.site_uuid;
|
filter.site_uuid = req.user.site_uuid;
|
||||||
|
|||||||
@@ -11,77 +11,94 @@ router.get('/summary', async (req, res) => {
|
|||||||
const base = getBaseFilter(req, timeFilter);
|
const base = getBaseFilter(req, timeFilter);
|
||||||
const baseWithoutTime = getBaseFilter(req, null);
|
const baseWithoutTime = getBaseFilter(req, null);
|
||||||
|
|
||||||
const latestDoc = await Summary.findOne(baseWithoutTime).sort({ timestamp: -1 });
|
|
||||||
|
|
||||||
let latestTime = null;
|
|
||||||
let bandwidthDown = 0;
|
let bandwidthDown = 0;
|
||||||
let bandwidthUp = 0;
|
let bandwidthUp = 0;
|
||||||
let totalDevicesCount = 0;
|
|
||||||
let activeFlowsCount = 0;
|
let activeFlowsCount = 0;
|
||||||
|
let downloadSpeed = 0;
|
||||||
|
let uploadSpeed = 0;
|
||||||
|
let latestTime = null;
|
||||||
|
|
||||||
if (latestDoc) {
|
if (base.agent_uuid) {
|
||||||
latestTime = latestDoc.timestamp;
|
// ── Agent-Level Summary (View As Agent mode) ─────────────────────────────
|
||||||
const summaries = await Summary.find({ ...baseWithoutTime, timestamp: latestTime }).lean();
|
// The proxy saves per-agent summaries with agent_uuid = <uuid>.
|
||||||
|
// Use the latest one for the scoped agent instead of site aggregates.
|
||||||
bandwidthDown = summaries.reduce((s, r) => s + (r.bandwidth_down ?? 0), 0);
|
const latestAgentSummary = await Summary
|
||||||
bandwidthUp = summaries.reduce((s, r) => s + (r.bandwidth_up ?? 0), 0);
|
.findOne(baseWithoutTime)
|
||||||
totalDevicesCount = summaries.reduce((s, r) => s + (r.total_devices ?? 0), 0);
|
.sort({ timestamp: -1 })
|
||||||
activeFlowsCount = summaries.reduce((s, r) => s + (r.active_flows ?? 0), 0);
|
.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),
|
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),
|
Threat.countDocuments(base),
|
||||||
Event.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({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: {
|
data: {
|
||||||
total_devices: finalDevices,
|
total_devices: uniqueDevices,
|
||||||
total_threats: realThreatsCount > 0 ? realThreatsCount : fallbackThreatsCount,
|
total_threats: realThreatsCount,
|
||||||
total_events: realEventsCount,
|
total_events: realEventsCount,
|
||||||
last_fetch: latestTime || new Date(),
|
last_fetch: latestTime || new Date(),
|
||||||
bandwidth_down: finalDown,
|
bandwidth_down: bandwidthDown,
|
||||||
bandwidth_up: finalUp,
|
bandwidth_up: bandwidthUp,
|
||||||
active_flows: finalActiveFlows,
|
active_flows: activeFlowsCount,
|
||||||
download_speed: latestDoc?.download_speed ?? 0,
|
download_speed: downloadSpeed,
|
||||||
upload_speed: latestDoc?.upload_speed ?? 0,
|
upload_speed: uploadSpeed,
|
||||||
flow_speed: 0,
|
flow_speed: 0,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -91,6 +108,9 @@ router.get('/summary', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// GET /api/dashboard/timeline
|
// GET /api/dashboard/timeline
|
||||||
router.get('/timeline', async (req, res) => {
|
router.get('/timeline', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -281,7 +281,10 @@ router.get('/intelligence/server-discovery', async (req, res) => {
|
|||||||
|
|
||||||
// Resolve IPs using DeviceStat
|
// Resolve IPs using DeviceStat
|
||||||
const macs = events.map(e => e.mac_address).filter(Boolean);
|
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 = {};
|
const macMap = {};
|
||||||
devices.forEach(d => {
|
devices.forEach(d => {
|
||||||
macMap[d.mac_address] = d;
|
macMap[d.mac_address] = d;
|
||||||
|
|||||||
@@ -48,30 +48,34 @@ module.exports = async function deviceDetailsHandler(req, res, helpers) {
|
|||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
getTimeFilter, generateMacFromIp,
|
getTimeFilter, getBaseFilter, generateMacFromIp,
|
||||||
resolveDeviceTypeFromIp, resolveOSFromIp, resolveVendorFromIp, generateAutoLabel
|
resolveDeviceTypeFromIp, resolveOSFromIp, resolveVendorFromIp, generateAutoLabel
|
||||||
} = helpers;
|
} = helpers;
|
||||||
|
|
||||||
|
const baseFilter = getBaseFilter(req);
|
||||||
|
|
||||||
let ip = String(req.query.ip ?? '');
|
let ip = String(req.query.ip ?? '');
|
||||||
const mac = String(req.query.mac ?? '');
|
const mac = String(req.query.mac ?? '');
|
||||||
|
|
||||||
if (!ip && 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) {
|
if (dev) {
|
||||||
ip = dev.ip_address;
|
ip = dev.ip_address;
|
||||||
} else {
|
} 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 (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 ?? '');
|
// Find device stats by ip if set, else by mac
|
||||||
const metaFilter = {};
|
const deviceQuery = ip ? { ip_address: ip } : { mac_address: mac };
|
||||||
if (req.user?.site_uuid) metaFilter.site_uuid = req.user.site_uuid;
|
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 = baseFilter.agent_uuid || device?.agent_uuid || null;
|
||||||
const agentUuid = agentUuidParam || device?.agent_uuid || req.user?.agent_uuid || null;
|
|
||||||
|
|
||||||
// ── PRIMARY bandwidth source ─────────────────────────────────────────────
|
// ── PRIMARY bandwidth source ─────────────────────────────────────────────
|
||||||
const totalDownload = device?.download || 0;
|
const totalDownload = device?.download || 0;
|
||||||
@@ -89,15 +93,35 @@ module.exports = async function deviceDetailsHandler(req, res, helpers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Parallel queries ─────────────────────────────────────────────────────
|
// ── 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 };
|
const appFilter = agentUuid ? { agent_uuid: agentUuid, ip_address: ip } : { ip_address: ip };
|
||||||
if (req.user?.site_uuid) appFilter.site_uuid = req.user.site_uuid;
|
if (req.user?.site_uuid) appFilter.site_uuid = req.user.site_uuid;
|
||||||
|
|
||||||
const [deviceAppStats, flowsQuery, rawThreats] = await Promise.all([
|
const [deviceAppStats, flowsQuery, rawThreats] = await Promise.all([
|
||||||
// PRIMARY: per-device per-app from DPI API (stored by proxy Step 3b)
|
// Only query DeviceAppStat if we have an IP
|
||||||
DeviceAppStat.find(appFilter).sort({ timestamp: -1 }).lean(),
|
ip ? DeviceAppStat.find(appFilter).sort({ timestamp: -1 }).lean() : [],
|
||||||
Flow.find({ ...flowFilter, $or: [{ src_ip: ip }, { dst_ip: ip }] }).sort({ timestamp: -1 }).limit(2000).lean(),
|
flowQueryConditions.length > 0
|
||||||
Threat.find({ ...(agentUuid ? { agent_uuid: agentUuid } : {}), ip_address: ip })
|
? Flow.find({ ...flowFilter, $or: flowQueryConditions }).sort({ timestamp: -1 }).limit(2000).lean()
|
||||||
.sort({ detected_at: -1 }).lean(),
|
: [],
|
||||||
|
Threat.find(threatQuery).sort({ detected_at: -1 }).lean(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── Apps tab — use DeviceAppStat (real DPI per-IP per-app data) ──────────
|
// ── 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) {
|
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 down = f.download || 0;
|
||||||
const up = f.upload || 0;
|
const up = f.upload || 0;
|
||||||
const ls = f.last_seen || (f.timestamp ? new Date(f.timestamp).toISOString() : new Date().toISOString());
|
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
|
const flows = flowsQuery
|
||||||
.filter(f => f.src_ip === ip)
|
.filter(f => ip ? (f.src_ip === ip) : (mac ? (f.src_mac === mac) : false))
|
||||||
.map(f => ({
|
.map(f => ({
|
||||||
flow_id: f.flow_id || f._id.toString(),
|
flow_id: f.flow_id || f._id.toString(),
|
||||||
src_ip: f.src_ip,
|
src_ip: f.src_ip,
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ module.exports = async function remoteIpDetailsHandler(req, res, helpers) {
|
|||||||
const flowFilter = {};
|
const flowFilter = {};
|
||||||
if (req.user?.site_uuid) flowFilter.site_uuid = req.user.site_uuid;
|
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) {
|
if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) {
|
||||||
flowFilter.agent_uuid = 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');
|
const rawTimeRange = String(req.query.timeRange ?? 'all');
|
||||||
|
|||||||
+14
-148
@@ -55,43 +55,15 @@ app.use('/api/auth', authRoutes);
|
|||||||
app.use('/api/uploads', express.static(getUploadsDir()));
|
app.use('/api/uploads', express.static(getUploadsDir()));
|
||||||
|
|
||||||
// ─── Auth Middleware ──────────────────────────────────────────────────────────
|
// ─── Auth Middleware ──────────────────────────────────────────────────────────
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-backone-key';
|
const { requireAuth } = require('./middleware/auth');
|
||||||
|
const { getTimeFilter, getBaseFilter } = require('./routes/dashboard/helpers');
|
||||||
function requireAuth(req, res, next) {
|
const {
|
||||||
const token = req.cookies?.token;
|
generateMacFromIp,
|
||||||
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
resolveVendorFromIp,
|
||||||
|
resolveDeviceTypeFromIp,
|
||||||
try {
|
resolveOSFromIp,
|
||||||
req.user = jwt.verify(token, JWT_SECRET);
|
generateAutoLabel
|
||||||
|
} = require('./deviceResolver');
|
||||||
// ── 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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Protected Dashboard Routes ───────────────────────────────────────────────
|
// ─── Protected Dashboard Routes ───────────────────────────────────────────────
|
||||||
const dashboardRoutes = require('./routes/dashboard');
|
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
|
// Override /api/dashboard/app-details to show real-time device mapping per application
|
||||||
app.get('/api/dashboard/app-details', requireAuth, (req, res) => {
|
app.get('/api/dashboard/app-details', requireAuth, (req, res) => {
|
||||||
require('./routes/appDetailsHandler')(req, res, {
|
require('./routes/appDetailsHandler')(req, res, {
|
||||||
getTimeFilter: (req) => {
|
getTimeFilter,
|
||||||
const range = req.query.timeRange || 'all';
|
getBaseFilter
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Override /api/dashboard/device-details to map real-time classifications (Facebook, YouTube, etc.)
|
// Override /api/dashboard/device-details to map real-time classifications (Facebook, YouTube, etc.)
|
||||||
app.get('/api/dashboard/device-details', requireAuth, (req, res) => {
|
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, {
|
require('./routes/deviceDetailsHandler')(req, res, {
|
||||||
// Device detail: default timeRange is 'all' so ALL historical data shows
|
getTimeFilter,
|
||||||
// Only respect explicit time filters if user deliberately passes one
|
getBaseFilter,
|
||||||
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;
|
|
||||||
},
|
|
||||||
generateMacFromIp,
|
generateMacFromIp,
|
||||||
resolveDeviceTypeFromIp,
|
resolveDeviceTypeFromIp,
|
||||||
resolveOSFromIp,
|
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) => {
|
app.get('/api/dashboard/remote-ip-details', requireAuth, async (req, res) => {
|
||||||
require('./routes/remoteIpDetailsHandler')(req, res, {
|
require('./routes/remoteIpDetailsHandler')(req, res, {
|
||||||
getTimeFilter: (req) => {
|
getTimeFilter
|
||||||
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) };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+8
-14
@@ -1,27 +1,21 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
name: "backone-frontend",
|
name: "backone-proxy",
|
||||||
script: "./.next/standalone/server.js",
|
script: "./proxy/index.js",
|
||||||
env: {
|
env_file: ".env.production"
|
||||||
NODE_ENV: "production",
|
|
||||||
PORT: 8009
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "backone-backend",
|
name: "backone-backend",
|
||||||
script: "./backend/server.js",
|
script: "./backend/server.js",
|
||||||
env: {
|
env_file: ".env.production"
|
||||||
NODE_ENV: "production",
|
|
||||||
PORT: 3001
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "backone-proxy",
|
name: "backone-frontend",
|
||||||
script: "./proxy/index.js",
|
script: "server.js",
|
||||||
|
env_file: ".env.production",
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: "production",
|
PORT: 8009
|
||||||
PORT: 4000
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
serverExternalPackages: ["mongoose"],
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: {
|
serverActions: {
|
||||||
allowedOrigins: ["demoplace.my.id", "www.demoplace.my.id"],
|
allowedOrigins: ["demoplace.my.id", "www.demoplace.my.id"],
|
||||||
|
|||||||
Generated
+17
@@ -8,6 +8,7 @@
|
|||||||
"name": "netify-app",
|
"name": "netify-app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"framer-motion": "^12.42.2",
|
"framer-motion": "^12.42.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
"mongodb-memory-server": "^11.2.0",
|
"mongodb-memory-server": "^11.2.0",
|
||||||
"mongoose": "^9.7.4",
|
"mongoose": "^9.7.4",
|
||||||
@@ -1860,6 +1862,15 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
@@ -6933,6 +6944,12 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev:next": "next dev",
|
"dev:next": "next dev",
|
||||||
"dev:backend": "node backend/server.js",
|
"dev:backend": "node backend/server.js",
|
||||||
"dev:proxy": "node proxy/index.js",
|
"dev:proxy": "node proxy/index.js",
|
||||||
|
"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",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"start:prod": "concurrently \"next start\" \"node backend/server.js\" \"node proxy/index.js\"",
|
"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 .."
|
"install:all": "npm install && cd backend && npm install && cd ../proxy && npm install && cd .."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"framer-motion": "^12.42.2",
|
"framer-motion": "^12.42.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
"mongodb-memory-server": "^11.2.0",
|
"mongodb-memory-server": "^11.2.0",
|
||||||
"mongoose": "^9.7.4",
|
"mongoose": "^9.7.4",
|
||||||
|
|||||||
@@ -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); });
|
||||||
@@ -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); });
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
+70
-3
@@ -124,6 +124,11 @@ async function collectAllAgents() {
|
|||||||
return { success: false, mode: 'all', message: 'No sites configured', results: [] };
|
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) {
|
for (const siteUuid of SITE_UUIDS) {
|
||||||
console.log(`[Collector] Fetching agents for Site: ${siteUuid}`);
|
console.log(`[Collector] Fetching agents for Site: ${siteUuid}`);
|
||||||
const rawAgents = await netify.fetchAgents(siteUuid);
|
const rawAgents = await netify.fetchAgents(siteUuid);
|
||||||
@@ -132,16 +137,78 @@ async function collectAllAgents() {
|
|||||||
continue;
|
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) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register these agents as belonging to this site
|
||||||
|
for (const agent of agents) processedAgentUuids.add(agent.uuid);
|
||||||
|
|
||||||
totalAgents += agents.length;
|
totalAgents += agents.length;
|
||||||
console.log(`[Collector] Processing ${agents.length} agents for site ${siteUuid}: ${agents.map(a => a.uuid).join(', ')}`);
|
console.log(`[Collector] Processing ${agents.length} agents for site ${siteUuid}: ${agents.map(a => a.uuid).join(', ')}`);
|
||||||
|
|
||||||
|
// ── 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) {
|
for (const agent of agents) {
|
||||||
const result = await collectForAgent(agent.uuid, timestamp, siteUuid);
|
const result = await collectForAgent(agent.uuid, timestamp, siteUuid);
|
||||||
results.push({ ...result, agent_label: agent.label, site_uuid: siteUuid });
|
results.push({ ...result, agent_label: agent.label, site_uuid: siteUuid });
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// Split from collector.js to satisfy the 256-line file size limit.
|
// 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 {
|
const {
|
||||||
generateMacFromIp,
|
generateMacFromIp,
|
||||||
resolveVendorFromIp,
|
resolveVendorFromIp,
|
||||||
@@ -108,6 +108,62 @@ async function collectFlows(agentUuid, timestamp, SITE_UUID, netify, label, ipTo
|
|||||||
await Flow.bulkWrite(operations);
|
await Flow.bulkWrite(operations);
|
||||||
console.log(`[Collector] ✓ ${flowDocs.length} flows upserted for ${label}`);
|
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)
|
// Removed 1-hour pruning to comply with Rule 19 (7-day global retention)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
+11
-232
@@ -1,21 +1,6 @@
|
|||||||
// proxy/index.js
|
// proxy/index.js
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// BackOne Proxy Server - Entry Point
|
// 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');
|
const path = require('path');
|
||||||
@@ -25,11 +10,12 @@ const express = require('express');
|
|||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const scheduler = require('./scheduler');
|
const scheduler = require('./scheduler');
|
||||||
|
const routes = require('./routes');
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PROXY_PORT || '4000');
|
const PORT = parseInt(process.env.PROXY_PORT || '4000');
|
||||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi';
|
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() {
|
async function connectDB() {
|
||||||
const MAX_RETRIES = 10;
|
const MAX_RETRIES = 10;
|
||||||
const RETRY_DELAYS = [2000, 3000, 5000, 5000, 10000, 10000, 10000, 15000, 15000, 30000];
|
const RETRY_DELAYS = [2000, 3000, 5000, 5000, 10000, 10000, 10000, 15000, 15000, 30000];
|
||||||
@@ -43,8 +29,8 @@ async function connectDB() {
|
|||||||
socketTimeoutMS: 30000,
|
socketTimeoutMS: 30000,
|
||||||
});
|
});
|
||||||
console.log('[MongoDB] ✓ Connected successfully to', MONGODB_URI);
|
console.log('[MongoDB] ✓ Connected successfully to', MONGODB_URI);
|
||||||
const { logCapacityStats } = require('../backend/db/capacityTracker');
|
const { logCapacityStats } = require('./db/capacityTracker');
|
||||||
await logCapacityStats('[MongoDB]');
|
logCapacityStats('[MongoDB]').catch(err => console.warn('[MongoDB] Capacity log failed:', err.message));
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[MongoDB] ✗ Attempt ${attempt} failed: ${err.message}`);
|
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] 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── REST API ──────────────────────────────────────────────────────────────────
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
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() {
|
async function main() {
|
||||||
console.log('\n╔════════════════════════════════════════════════╗');
|
console.log('\n╔════════════════════════════════════════════════╗');
|
||||||
console.log('║ BackOne Proxy Server - Starting ║');
|
console.log('║ BackOne Proxy Server - Starting ║');
|
||||||
console.log('╚════════════════════════════════════════════════╝\n');
|
console.log('╚════════════════════════════════════════════════╝\n');
|
||||||
console.log(`[Proxy] Mode: ${process.env.PROXY_COLLECT_MODE || 'all'}`);
|
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!)'}`);
|
// Start REST API server first so liveness probes remain active
|
||||||
}
|
|
||||||
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)
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`\n🚀 Proxy REST API running at http://0.0.0.0:${PORT}`);
|
console.log(`\n🚀 Proxy REST API running at http://0.0.0.0:${PORT}`);
|
||||||
console.log(` GET /health → liveness check`);
|
console.log(` GET /health → liveness check`);
|
||||||
console.log(` GET /status → scheduler + DB status`);
|
console.log(` GET /status → scheduler + DB status\n`);
|
||||||
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`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Connect to MongoDB (retry in background)
|
|
||||||
const connected = await connectDB();
|
const connected = await connectDB();
|
||||||
|
|
||||||
// 3. Start scheduler only if DB connected
|
|
||||||
if (connected) {
|
if (connected) {
|
||||||
scheduler.startScheduler();
|
scheduler.startScheduler();
|
||||||
console.log('\n[Proxy] ✓ Scheduler started. Data collection is active.');
|
console.log('\n[Proxy] ✓ Scheduler started. Data collection is active.\n');
|
||||||
console.log('[Proxy] ✓ First collection will run in 2 seconds after MongoDB is ready.\n');
|
|
||||||
} else {
|
} else {
|
||||||
console.error('\n[Proxy] ✗ Could not connect to MongoDB. Scheduler NOT started.');
|
console.error('\n[Proxy] ✗ Could not connect to MongoDB. Scheduler NOT started.\n');
|
||||||
console.error('[Proxy] ✗ To fix: ensure MongoDB is running at', MONGODB_URI);
|
|
||||||
console.error('[Proxy] ✗ Then restart the proxy server.\n');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(err => {
|
main().catch(err => {
|
||||||
console.error('[Proxy] Fatal startup error:', err);
|
console.error('[Proxy] Fatal startup error:', err);
|
||||||
// Don't exit — let REST API still serve /health
|
|
||||||
});
|
});
|
||||||
+5
-80
@@ -78,7 +78,7 @@ const FlowSchema = new mongoose.Schema({
|
|||||||
site_uuid: { type: String, index: true },
|
site_uuid: { type: String, index: true },
|
||||||
flow_id: String,
|
flow_id: String,
|
||||||
src_ip: { type: String, index: true },
|
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_ip: { type: String, index: true },
|
||||||
dst_port: Number,
|
dst_port: Number,
|
||||||
protocol: String,
|
protocol: String,
|
||||||
@@ -131,52 +131,6 @@ const EventSchema = new mongoose.Schema({
|
|||||||
event_at: Date,
|
event_at: Date,
|
||||||
}, baseOptions);
|
}, 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 ─────────────────────────────
|
// ─── Compound indexes for common dashboard queries ─────────────────────────────
|
||||||
SummarySchema.index({ agent_uuid: 1, timestamp: -1 });
|
SummarySchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
AppStatSchema.index({ agent_uuid: 1, timestamp: -1, download: -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 });
|
ThreatSchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
AppCategoryStatSchema.index({ agent_uuid: 1, timestamp: -1 });
|
AppCategoryStatSchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
EventSchema.index({ agent_uuid: 1, timestamp: -1 });
|
EventSchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
TlsVersionStatSchema.index({ agent_uuid: 1, timestamp: -1 });
|
FlowSchema.index({ site_uuid: 1, src_mac: 1, timestamp: -1 }); // for MAC-to-IP resolution
|
||||||
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 ────────────────────────────────────────
|
// ── Per-Device Per-Application Stats ────────────────────────────────────────
|
||||||
// Collected from DPI API: /data/stats/top/application/download with filter_local_ips
|
// 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({ ip_address: 1, app_label: 1, timestamp: -1 });
|
||||||
DeviceAppStatSchema.index({ site_uuid: 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 telemetrySchemas = require('./SchemasTelemetry');
|
||||||
|
const auxSchemas = require('./SchemasAux');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
Summary: mongoose.model('Summary', SummarySchema),
|
Summary: mongoose.model('Summary', SummarySchema),
|
||||||
@@ -244,14 +173,10 @@ module.exports = {
|
|||||||
DeviceAppStat: mongoose.model('DeviceAppStat', DeviceAppStatSchema),
|
DeviceAppStat: mongoose.model('DeviceAppStat', DeviceAppStatSchema),
|
||||||
Flow: mongoose.model('Flow', FlowSchema),
|
Flow: mongoose.model('Flow', FlowSchema),
|
||||||
Threat: mongoose.model('Threat', ThreatSchema),
|
Threat: mongoose.model('Threat', ThreatSchema),
|
||||||
CustomDeviceLabel: mongoose.model('CustomDeviceLabel', CustomDeviceLabelSchema),
|
|
||||||
AppCategoryStat: mongoose.model('AppCategoryStat', AppCategoryStatSchema),
|
AppCategoryStat: mongoose.model('AppCategoryStat', AppCategoryStatSchema),
|
||||||
Event: mongoose.model('Event', EventSchema),
|
Event: mongoose.model('Event', EventSchema),
|
||||||
TlsVersionStat: mongoose.model('TlsVersionStat', TlsVersionStatSchema),
|
...auxSchemas,
|
||||||
TlsCipherStat: mongoose.model('TlsCipherStat', TlsCipherStatSchema),
|
|
||||||
TlsSecurityStat: mongoose.model('TlsSecurityStat', TlsSecurityStatSchema),
|
|
||||||
CountryStat: mongoose.model('CountryStat', CountryStatSchema),
|
|
||||||
LookupApp: mongoose.model('LookupApp', LookupAppSchema),
|
|
||||||
...telemetrySchemas,
|
...telemetrySchemas,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
+39
-9
@@ -1,10 +1,9 @@
|
|||||||
// proxy/netifyClient.js
|
// proxy/netifyClient.js
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Clean DPI API wrapper for the BackOne Proxy Server
|
// DPI API wrapper for the BackOne Proxy Server targeting original Netify API.
|
||||||
// Re-exports modules split into logical units to satisfy the 256-line limit.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const { netifyFetch, agentMap } = require('./netifyClientCore');
|
const { netifyFetch, BASE_URL, agentMap } = require('./netifyClientCore');
|
||||||
const telemetry = require('./netifyTelemetry');
|
const telemetry = require('./netifyTelemetry');
|
||||||
|
|
||||||
const PORT_SERVICE_MAP = {
|
const PORT_SERVICE_MAP = {
|
||||||
@@ -21,19 +20,48 @@ const PORT_SERVICE_MAP = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function fetchAgents(siteUuid = null) {
|
async function fetchAgents(siteUuid = null) {
|
||||||
const data = await netifyFetch('/data/stats/top/agent/download', {
|
const data = await netifyFetch('/data/stats/top/agent/download', { filter_interval: 43200, settings_limit: 100 }, null, siteUuid);
|
||||||
filter_interval: 43200, settings_limit: 100
|
|
||||||
}, null, siteUuid);
|
|
||||||
if (!data || !Array.isArray(data)) return [];
|
if (!data || !Array.isArray(data)) return [];
|
||||||
const list = data
|
const list = data.map(r => ({
|
||||||
.map(r => ({ id: r.agent?.id, uuid: r.agent?.uuid, label: r.agent?.label }))
|
id: r.agent?.id,
|
||||||
.filter(a => a.uuid);
|
uuid: r.agent?.uuid,
|
||||||
|
label: r.agent?.label,
|
||||||
|
})).filter(a => a.uuid);
|
||||||
|
|
||||||
for (const a of list) {
|
for (const a of list) {
|
||||||
if (a.uuid && a.id) agentMap[a.uuid] = a.id;
|
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;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function fetchBandwidthSummary(interval = 1440, agentUuid = null, siteUuid = null) {
|
async function fetchBandwidthSummary(interval = 1440, agentUuid = null, siteUuid = null) {
|
||||||
const [dlData, ulData, flowsData] = await Promise.all([
|
const [dlData, ulData, flowsData] = await Promise.all([
|
||||||
netifyFetch('/data/stats/top/local_ip/download', { filter_interval: interval, settings_limit: 500 }, agentUuid, siteUuid),
|
netifyFetch('/data/stats/top/local_ip/download', { filter_interval: interval, settings_limit: 500 }, agentUuid, siteUuid),
|
||||||
@@ -237,5 +265,7 @@ module.exports = {
|
|||||||
fetchFlows,
|
fetchFlows,
|
||||||
fetchCyberThreats,
|
fetchCyberThreats,
|
||||||
fetchEvents,
|
fetchEvents,
|
||||||
|
BASE_URL,
|
||||||
|
PORT_SERVICE_MAP,
|
||||||
...telemetry,
|
...telemetry,
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
// proxy/netifyClientCore.js
|
// proxy/netifyClientCore.js
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Core fetch and authentication helpers for Netify DPI API.
|
// Core fetch and authentication helpers for Netify DPI API.
|
||||||
// Split from netifyClient.js to keep file sizes under 256 lines.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const path = require('path');
|
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 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;
|
const JWT_TOKEN = process.env.NETIFY_TOKEN || process.env.NETIFY_JWT_TOKEN;
|
||||||
// In-memory map: agent_uuid -> numeric agent ID
|
|
||||||
const agentMap = {};
|
const agentMap = {};
|
||||||
|
|
||||||
function getHeaders(siteUuid) {
|
function getHeaders(siteUuid) {
|
||||||
@@ -39,13 +37,16 @@ function fixDates(obj) {
|
|||||||
async function netifyFetch(endpoint, params = {}, agentUuid = null, siteUuid = null) {
|
async function netifyFetch(endpoint, params = {}, agentUuid = null, siteUuid = null) {
|
||||||
if (agentUuid) {
|
if (agentUuid) {
|
||||||
const agentId = agentMap[agentUuid];
|
const agentId = agentMap[agentUuid];
|
||||||
if (agentId) params.filter_agents = `[${agentId}]`;
|
if (agentId) {
|
||||||
else params.settings_agent = agentUuid;
|
params.filter_agents = `[${agentId}]`;
|
||||||
|
} else {
|
||||||
|
params.settings_agent = agentUuid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = process.env.NETIFY_API_KEY || JWT_TOKEN;
|
const token = process.env.NETIFY_API_KEY || JWT_TOKEN;
|
||||||
if (!token || !siteUuid) {
|
if (!token) {
|
||||||
console.error(`[DpiClient] Missing DPI_API_KEY or siteUuid for endpoint ${endpoint}`);
|
console.error(`[DpiClient] Missing DPI_API_KEY for endpoint ${endpoint}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +55,8 @@ async function netifyFetch(endpoint, params = {}, agentUuid = null, siteUuid = n
|
|||||||
headers: getHeaders(siteUuid), params, timeout: 30000,
|
headers: getHeaders(siteUuid), params, timeout: 30000,
|
||||||
});
|
});
|
||||||
const json = res.data;
|
const json = res.data;
|
||||||
|
|
||||||
if (json?.status_code !== 0) {
|
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'}`);
|
console.error(`[DpiClient] API Error ${json?.status_code} on ${endpoint}: ${json?.status_message || 'No message'}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+194
@@ -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;
|
||||||
+1
-1
@@ -55,7 +55,7 @@ async function runCollection() {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastCapacityLogAt >= CAPACITY_LOG_INTERVAL_MS) {
|
if (now - lastCapacityLogAt >= CAPACITY_LOG_INTERVAL_MS) {
|
||||||
lastCapacityLogAt = now;
|
lastCapacityLogAt = now;
|
||||||
const { logCapacityStats } = require('../backend/db/capacityTracker');
|
const { logCapacityStats } = require('./db/capacityTracker');
|
||||||
await logCapacityStats(`[PROXY] [MongoDB] Capacity Used after Run #${runCount}:`);
|
await logCapacityStats(`[PROXY] [MongoDB] Capacity Used after Run #${runCount}:`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
@@ -11,7 +11,7 @@ const config = {
|
|||||||
password: 'htEo7x6LsBQiEHHH'
|
password: 'htEo7x6LsBQiEHHH'
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEPLOY_DIR = 'backone-production';
|
const DEPLOY_DIR = 'web/demoplace.my.id/public_html';
|
||||||
|
|
||||||
async function createZip() {
|
async function createZip() {
|
||||||
console.log("Packaging application...");
|
console.log("Packaging application...");
|
||||||
@@ -30,11 +30,15 @@ async function createZip() {
|
|||||||
// Add static assets (Next.js standalone requires these copied over)
|
// Add static assets (Next.js standalone requires these copied over)
|
||||||
archive.directory(path.join(__dirname, '../.next/static'), '.next/static');
|
archive.directory(path.join(__dirname, '../.next/static'), '.next/static');
|
||||||
archive.directory(path.join(__dirname, '../public'), 'public');
|
archive.directory(path.join(__dirname, '../public'), 'public');
|
||||||
|
archive.directory(path.join(__dirname, '../public'), false);
|
||||||
|
|
||||||
// Add backend and proxy
|
// Add backend and proxy
|
||||||
archive.directory(path.join(__dirname, '../backend'), 'backend');
|
archive.directory(path.join(__dirname, '../backend'), 'backend');
|
||||||
archive.directory(path.join(__dirname, '../proxy'), 'proxy');
|
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
|
// Add production env
|
||||||
archive.file(path.join(__dirname, '../.env.production'), { name: '.env.production' });
|
archive.file(path.join(__dirname, '../.env.production'), { name: '.env.production' });
|
||||||
|
|
||||||
@@ -81,26 +85,24 @@ async function deploy() {
|
|||||||
await sftp.put(zipPath, `${targetDir}/deploy.zip`);
|
await sftp.put(zipPath, `${targetDir}/deploy.zip`);
|
||||||
|
|
||||||
console.log("Extracting package on server...");
|
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...");
|
console.log("Creating symlink for static files...");
|
||||||
await runSSH(`cd ${targetDir}/backend && npm install --production`);
|
await runSSH(`cd ${targetDir} && rm -rf _next && ln -s .next _next`);
|
||||||
await runSSH(`cd ${targetDir}/proxy && npm install --production`);
|
|
||||||
|
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...");
|
console.log("Configuring PM2...");
|
||||||
// Create ecosystem file for PM2
|
// PM2 ecosystem is now packaged in deploy.zip directly, no need to generate via bash echo.
|
||||||
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`);
|
|
||||||
|
|
||||||
console.log("Restarting PM2 processes...");
|
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(pm2Result);
|
||||||
|
|
||||||
console.log("Deployment completed successfully!");
|
console.log("Deployment completed successfully!");
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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<void>;
|
||||||
|
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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<AgentModals
|
||||||
|
isCreateOpen={isCreateOpen}
|
||||||
|
setIsCreateOpen={setIsCreateOpen}
|
||||||
|
isDeleteOpen={isDeleteOpen}
|
||||||
|
setIsDeleteOpen={setIsDeleteOpen}
|
||||||
|
selectedAgent={selectedAgent}
|
||||||
|
onSuccess={loadAgents}
|
||||||
|
detailAgent={detailAgent}
|
||||||
|
setDetailAgent={setDetailAgent}
|
||||||
|
accountModalOpen={accountModalOpen}
|
||||||
|
setAccountModalOpen={setAccountModalOpen}
|
||||||
|
accountModalAgent={accountModalAgent}
|
||||||
|
setAccountModalAgent={setAccountModalAgent}
|
||||||
|
managedUsers={managedUsers}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExternalAccountModal
|
||||||
|
isOpen={externalModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setExternalModalOpen(false);
|
||||||
|
setExternalModalUser(null);
|
||||||
|
setTargetSiteUuid(null);
|
||||||
|
setTargetCreatedBy(null);
|
||||||
|
}}
|
||||||
|
onSuccess={loadAgents}
|
||||||
|
user={externalModalUser}
|
||||||
|
targetSiteUuid={targetSiteUuid}
|
||||||
|
targetCreatedBy={targetCreatedBy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AgentLocationModal
|
||||||
|
isOpen={isLocModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsLocModalOpen(false);
|
||||||
|
setLocModalAgentUuid("");
|
||||||
|
setLocModalAgentLabel("");
|
||||||
|
setLocModalCurrentLoc(null);
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
loadLocations();
|
||||||
|
}}
|
||||||
|
agentUuid={locModalAgentUuid}
|
||||||
|
agentLabel={locModalAgentLabel}
|
||||||
|
currentLocation={locModalCurrentLoc}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Agent>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentsTableSection({
|
||||||
|
agents,
|
||||||
|
isLoading,
|
||||||
|
columns,
|
||||||
|
}: AgentsTableSectionProps) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">Provisioned Network Agents ({agents.length})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={agents}
|
||||||
|
columns={columns}
|
||||||
|
searchPlaceholder="Search agents..."
|
||||||
|
searchFilter={(row, query) => {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
(row.uuid || "").toLowerCase().includes(q) ||
|
||||||
|
(row.serial || "").toLowerCase().includes(q) ||
|
||||||
|
(row.label || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ManagedUser>[];
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Table 1: Superadmin (BackOne) Accounts */}
|
||||||
|
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<span>Superadmin (BackOne) External Accounts ({superadminUsers.length})</span>
|
||||||
|
</CardTitle>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setExternalModalUser(null);
|
||||||
|
setTargetSiteUuid(null);
|
||||||
|
setTargetCreatedBy('admin');
|
||||||
|
setExternalModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Add External Account
|
||||||
|
</button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={superadminUsers}
|
||||||
|
columns={externalColumns}
|
||||||
|
searchPlaceholder="Search accounts..."
|
||||||
|
searchFilter={(row, query) => {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
(row.username || "").toLowerCase().includes(q) ||
|
||||||
|
(row.account_name || "").toLowerCase().includes(q) ||
|
||||||
|
(row.role || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Table 2: SIAB Accounts */}
|
||||||
|
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-white">SIAB Tenant External Accounts ({siabUsers.length})</CardTitle>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setExternalModalUser(null);
|
||||||
|
setTargetSiteUuid('6681452d_9cae_4ff4_8ae8_0d504774265e');
|
||||||
|
setTargetCreatedBy('siab');
|
||||||
|
setExternalModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Add External Account
|
||||||
|
</button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={siabUsers}
|
||||||
|
columns={externalColumns}
|
||||||
|
searchPlaceholder="Search accounts..."
|
||||||
|
searchFilter={(row, query) => {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
(row.username || "").toLowerCase().includes(q) ||
|
||||||
|
(row.account_name || "").toLowerCase().includes(q) ||
|
||||||
|
(row.role || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Table 3: Nexus Accounts */}
|
||||||
|
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-white">Nexus Tenant External Accounts ({nexusUsers.length})</CardTitle>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setExternalModalUser(null);
|
||||||
|
setTargetSiteUuid('d7902405_0dc2_458b_8584_ed4d24b64f24');
|
||||||
|
setTargetCreatedBy('nexus');
|
||||||
|
setExternalModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Add External Account
|
||||||
|
</button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={nexusUsers}
|
||||||
|
columns={externalColumns}
|
||||||
|
searchPlaceholder="Search accounts..."
|
||||||
|
searchFilter={(row, query) => {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
(row.username || "").toLowerCase().includes(q) ||
|
||||||
|
(row.account_name || "").toLowerCase().includes(q) ||
|
||||||
|
(row.role || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'TENANT_ADMIN') {
|
||||||
|
return (
|
||||||
|
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-white">External Accounts Inventory ({externalUsers.length})</CardTitle>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setExternalModalUser(null);
|
||||||
|
setTargetSiteUuid(null);
|
||||||
|
setTargetCreatedBy(null);
|
||||||
|
setExternalModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Add External Account
|
||||||
|
</button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={externalUsers}
|
||||||
|
columns={externalColumns}
|
||||||
|
searchPlaceholder="Search accounts..."
|
||||||
|
searchFilter={(row, query) => {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
(row.username || "").toLowerCase().includes(q) ||
|
||||||
|
(row.account_name || "").toLowerCase().includes(q) ||
|
||||||
|
(row.role || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-xs font-semibold">
|
||||||
|
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
|
||||||
|
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||||
|
<Server className="w-12 h-12 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-slate-400 uppercase tracking-wider mb-1">Total Agents</span>
|
||||||
|
<span className="text-2xl font-black text-white tracking-tight">{totalAgents}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
|
||||||
|
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||||
|
<CheckCircle2 className="w-12 h-12 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-slate-400 uppercase tracking-wider mb-1">Online Agents</span>
|
||||||
|
<span className="text-2xl font-black text-emerald-400 tracking-tight">{onlineAgents}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
|
||||||
|
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||||
|
<XCircle className="w-12 h-12 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-slate-400 uppercase tracking-wider mb-1">Offline Agents</span>
|
||||||
|
<span className="text-2xl font-black text-red-400 tracking-tight">{offlineAgents}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
|
||||||
|
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||||
|
<Activity className="w-12 h-12 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-slate-400 uppercase tracking-wider mb-1">Avg. Historical Uptime</span>
|
||||||
|
<span className="text-2xl font-black text-cyan-400 tracking-tight">{avgUptime.toFixed(2)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Column } from "@/components/ui/DataTable";
|
|||||||
import { Agent } from "@/lib/actions/agents";
|
import { Agent } from "@/lib/actions/agents";
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import { resolveAgentLabel, type ManagedUser } from "@/lib/admin-api";
|
import { resolveAgentLabel, type ManagedUser } from "@/lib/admin-api";
|
||||||
import { Loader2, Trash2, CheckCircle2, XCircle, ChevronRight, UserCog, Eye } from "lucide-react";
|
import { Loader2, Trash2, CheckCircle2, XCircle, ChevronRight, UserCog, Eye, MapPin } from "lucide-react";
|
||||||
|
|
||||||
interface GetColumnsProps {
|
interface GetColumnsProps {
|
||||||
role: string | null;
|
role: string | null;
|
||||||
@@ -17,6 +17,8 @@ interface GetColumnsProps {
|
|||||||
setAccountModalAgent: (agent: { uuid: string; label: string } | null) => void;
|
setAccountModalAgent: (agent: { uuid: string; label: string } | null) => void;
|
||||||
handleViewAs: (uuid: string, label: string) => void;
|
handleViewAs: (uuid: string, label: string) => void;
|
||||||
openDelete: (agent: Agent) => void;
|
openDelete: (agent: Agent) => void;
|
||||||
|
onEditLocation?: (agentUuid: string, agentLabel: string) => void;
|
||||||
|
locations?: { agent_uuid: string; latitude: number; longitude: number; label?: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAgentColumns({
|
export function getAgentColumns({
|
||||||
@@ -30,11 +32,14 @@ export function getAgentColumns({
|
|||||||
setAccountModalAgent,
|
setAccountModalAgent,
|
||||||
handleViewAs,
|
handleViewAs,
|
||||||
openDelete,
|
openDelete,
|
||||||
|
onEditLocation,
|
||||||
|
locations = [],
|
||||||
}: GetColumnsProps): Column<Agent>[] {
|
}: GetColumnsProps): Column<Agent>[] {
|
||||||
|
const isSuperAdmin = role === 'SUPER_ADMIN';
|
||||||
const cols: Column<Agent>[] = [
|
const cols: Column<Agent>[] = [
|
||||||
{
|
{
|
||||||
header: "UUID / Serial",
|
header: "UUID / Serial",
|
||||||
className: "w-[16%] text-center",
|
className: isSuperAdmin ? "w-[12%] text-center" : "w-[14%] text-center",
|
||||||
accessor: (row) => (
|
accessor: (row) => (
|
||||||
<button onClick={() => setDetailAgent(row)} className="flex items-center justify-center gap-2 group text-center mx-auto" title="Click to view agent details">
|
<button onClick={() => setDetailAgent(row)} className="flex items-center justify-center gap-2 group text-center mx-auto" title="Click to view agent details">
|
||||||
<span className="font-mono text-xs text-blue-400 group-hover:text-blue-300 transition-colors">{row.uuid || row.serial}</span>
|
<span className="font-mono text-xs text-blue-400 group-hover:text-blue-300 transition-colors">{row.uuid || row.serial}</span>
|
||||||
@@ -44,7 +49,7 @@ export function getAgentColumns({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Label",
|
header: "Label",
|
||||||
className: "w-[24%] text-center",
|
className: isSuperAdmin ? "w-[18%] text-center" : "w-[24%] text-center",
|
||||||
accessor: (row) => {
|
accessor: (row) => {
|
||||||
const uuid = row.uuid || row.serial;
|
const uuid = row.uuid || row.serial;
|
||||||
const dynamicLabel = resolveAgentLabel(uuid, managedUsers);
|
const dynamicLabel = resolveAgentLabel(uuid, managedUsers);
|
||||||
@@ -63,17 +68,17 @@ export function getAgentColumns({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Provisioned",
|
header: "Provisioned",
|
||||||
className: "w-[12%] text-center",
|
className: isSuperAdmin ? "w-[10%] text-center" : "w-[11%] text-center",
|
||||||
accessor: (row) => row.provisioned ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge>
|
accessor: (row) => row.provisioned ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Activated",
|
header: "Activated",
|
||||||
className: "w-[12%] text-center",
|
className: isSuperAdmin ? "w-[10%] text-center" : "w-[11%] text-center",
|
||||||
accessor: (row) => row.activated ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge>
|
accessor: (row) => row.activated ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Status",
|
header: "Status",
|
||||||
className: "w-[11%] text-center",
|
className: "w-[12%] text-center",
|
||||||
accessor: (row) => {
|
accessor: (row) => {
|
||||||
const statusText = row.last_seen_at?.human || '-';
|
const statusText = row.last_seen_at?.human || '-';
|
||||||
if (statusText === 'Online') {
|
if (statusText === 'Online') {
|
||||||
@@ -87,7 +92,7 @@ export function getAgentColumns({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Historical Uptime",
|
header: "Historical Uptime",
|
||||||
className: "w-[12%] text-center",
|
className: "w-[10%] text-center",
|
||||||
accessor: (row) => {
|
accessor: (row) => {
|
||||||
const uuid = row.uuid || row.serial;
|
const uuid = row.uuid || row.serial;
|
||||||
const uptime = uptimeMap[uuid] ?? 100;
|
const uptime = uptimeMap[uuid] ?? 100;
|
||||||
@@ -99,7 +104,7 @@ export function getAgentColumns({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Actions",
|
header: "Actions",
|
||||||
className: "w-[13%] text-center",
|
className: "w-[18%] text-center",
|
||||||
accessor: (row) => {
|
accessor: (row) => {
|
||||||
const uuid = row.uuid || row.serial;
|
const uuid = row.uuid || row.serial;
|
||||||
const dynamicLabel = resolveAgentLabel(uuid, managedUsers);
|
const dynamicLabel = resolveAgentLabel(uuid, managedUsers);
|
||||||
@@ -112,6 +117,22 @@ export function getAgentColumns({
|
|||||||
</button>
|
</button>
|
||||||
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
|
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
|
||||||
<>
|
<>
|
||||||
|
{onEditLocation && (() => {
|
||||||
|
const hasCoords = locations.some(l => l.agent_uuid === uuid);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEditLocation(uuid, dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid)); }}
|
||||||
|
className={`p-1.5 rounded transition-colors ${
|
||||||
|
hasCoords
|
||||||
|
? "text-blue-400 hover:text-blue-300 hover:bg-blue-950/30"
|
||||||
|
: "text-red-400/60 hover:text-red-400 hover:bg-red-950/30"
|
||||||
|
}`}
|
||||||
|
title={hasCoords ? "Edit Geolocation (Dikonfigurasi)" : "Configure Geolocation (Belum Dikonfigurasi)"}
|
||||||
|
>
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setAccountModalAgent({ uuid, label: dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid) }); setAccountModalOpen(true); }}
|
onClick={(e) => { e.stopPropagation(); setAccountModalAgent({ uuid, label: dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid) }); setAccountModalOpen(true); }}
|
||||||
className="p-1.5 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-950/30 rounded transition-colors"
|
className="p-1.5 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-950/30 rounded transition-colors"
|
||||||
@@ -141,11 +162,11 @@ export function getAgentColumns({
|
|||||||
if (role === 'SUPER_ADMIN') {
|
if (role === 'SUPER_ADMIN') {
|
||||||
cols.splice(cols.length - 1, 0, {
|
cols.splice(cols.length - 1, 0, {
|
||||||
header: "Data Size",
|
header: "Data Size",
|
||||||
className: "w-[12%] text-center",
|
className: "w-[10%] text-center",
|
||||||
accessor: (row) => {
|
accessor: (row) => {
|
||||||
const uuid = row.uuid || row.serial;
|
const uuid = row.uuid || row.serial;
|
||||||
const sizeMB = storageMap[uuid] ?? 0;
|
const sizeMB = storageMap[uuid] ?? 0;
|
||||||
return <span className="font-mono text-slate-300 font-semibold">{sizeMB.toFixed(2)} MB</span>;
|
return <span className="font-mono text-slate-300 font-semibold">{formatStorageSize(sizeMB)}</span>;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -153,75 +174,17 @@ export function getAgentColumns({
|
|||||||
return cols;
|
return cols;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getExternalAccountColumns(
|
function formatStorageSize(sizeMB: number): string {
|
||||||
onManageAccount: (user: ManagedUser) => void
|
if (sizeMB === 0) return "0.00 MB";
|
||||||
): Column<ManagedUser>[] {
|
if (sizeMB >= 1024 * 1024) {
|
||||||
return [
|
return `${(sizeMB / (1024 * 1024)).toFixed(2)} TB`;
|
||||||
{
|
}
|
||||||
header: "Account Name",
|
if (sizeMB >= 1024) {
|
||||||
className: "w-[25%] text-left",
|
return `${(sizeMB / 1024).toFixed(2)} GB`;
|
||||||
accessor: (row) => (
|
}
|
||||||
<div className="flex items-center gap-3">
|
if (sizeMB < 1) {
|
||||||
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center border border-slate-700 shrink-0 overflow-hidden">
|
return `${(sizeMB * 1024).toFixed(2)} KB`;
|
||||||
{row.profile_picture ? (
|
}
|
||||||
<img src={`/api/uploads/${row.profile_picture}`} alt={row.account_name || row.username} className="w-full h-full object-cover" />
|
return `${sizeMB.toFixed(2)} MB`;
|
||||||
) : (
|
|
||||||
<span className="text-xs font-bold text-slate-400">{(row.account_name || row.username).substring(0, 2).toUpperCase()}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-white">{row.account_name || "-"}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Username",
|
|
||||||
className: "w-[25%] text-left",
|
|
||||||
accessor: (row) => <span className="text-slate-300 font-mono text-sm">{row.username}</span>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Role",
|
|
||||||
className: "w-[20%] text-center",
|
|
||||||
accessor: (row) => {
|
|
||||||
let badgeColor = "bg-slate-500/20 text-slate-400 border-slate-500/30";
|
|
||||||
if (row.role === 'SUPER_ADMIN') badgeColor = "bg-purple-500/20 text-purple-400 border-purple-500/30";
|
|
||||||
if (row.role === 'SOC_ANALYST') badgeColor = "bg-amber-500/20 text-amber-400 border-amber-500/30";
|
|
||||||
if (row.role === 'ENGINEER') badgeColor = "bg-blue-500/20 text-blue-400 border-blue-500/30";
|
|
||||||
if (row.role === 'TENANT_ADMIN') badgeColor = "bg-emerald-500/20 text-emerald-400 border-emerald-500/30";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold border ${badgeColor}`}>
|
|
||||||
{row.role.replace('_', ' ')}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Status",
|
|
||||||
className: "w-[15%] text-center",
|
|
||||||
accessor: (row) => (
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
{/* Defaulting to active since we don't have is_active explicitly in ManagedUser interface yet, though API returns it. We'll check if it exists, else assume Active */}
|
|
||||||
{(row as any).is_active !== false ? (
|
|
||||||
<Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Active</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> Inactive</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Actions",
|
|
||||||
className: "w-[15%] text-center",
|
|
||||||
accessor: (row) => (
|
|
||||||
<button
|
|
||||||
onClick={() => onManageAccount(row)}
|
|
||||||
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors disabled:opacity-50"
|
|
||||||
title="Manage Account"
|
|
||||||
disabled={row.role === 'SUPER_ADMIN'}
|
|
||||||
>
|
|
||||||
<UserCog className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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<ManagedUser>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: "Account Name",
|
||||||
|
className: "w-[25%] text-left",
|
||||||
|
accessor: (row) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center border border-slate-700 shrink-0 overflow-hidden">
|
||||||
|
{row.profile_picture ? (
|
||||||
|
<img src={`/api/uploads/${row.profile_picture}`} alt={row.account_name || row.username} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-bold text-slate-400">{(row.account_name || row.username).substring(0, 2).toUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-white">{row.account_name || "-"}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Username",
|
||||||
|
className: "w-[25%] text-left",
|
||||||
|
accessor: (row) => <span className="text-slate-300 font-mono text-sm">{row.username}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Role",
|
||||||
|
className: "w-[20%] text-center",
|
||||||
|
accessor: (row) => {
|
||||||
|
let badgeColor = "bg-slate-500/20 text-slate-400 border-slate-500/30";
|
||||||
|
if (row.role === 'SUPER_ADMIN') badgeColor = "bg-purple-500/20 text-purple-400 border-purple-500/30";
|
||||||
|
if (row.role === 'SOC_ANALYST') badgeColor = "bg-amber-500/20 text-amber-400 border-amber-500/30";
|
||||||
|
if (row.role === 'ENGINEER') badgeColor = "bg-blue-500/20 text-blue-400 border-blue-500/30";
|
||||||
|
if (row.role === 'TENANT_ADMIN') badgeColor = "bg-emerald-500/20 text-emerald-400 border-emerald-500/30";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold border ${badgeColor}`}>
|
||||||
|
{row.role.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Status",
|
||||||
|
className: "w-[15%] text-center",
|
||||||
|
accessor: (row) => (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{(row as any).is_active !== false ? (
|
||||||
|
<Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Active</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> Inactive</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Actions",
|
||||||
|
className: "w-[15%] text-center",
|
||||||
|
accessor: (row) => (
|
||||||
|
<button
|
||||||
|
onClick={() => onManageAccount(row)}
|
||||||
|
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors disabled:opacity-50"
|
||||||
|
title="Manage Account"
|
||||||
|
disabled={row.role === 'SUPER_ADMIN'}
|
||||||
|
>
|
||||||
|
<UserCog className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
+139
-257
@@ -1,151 +1,75 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { Loader2, Plus, Server } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { getAgentColumns } from "./columns";
|
||||||
import { getAgents, Agent } from "@/lib/actions/agents";
|
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";
|
const IndonesiaAgentMap = dynamic(
|
||||||
import { DataTable } from "@/components/ui/DataTable";
|
() => import("@/components/dashboard/IndonesiaAgentMap").then(m => ({ default: m.IndonesiaAgentMap })),
|
||||||
import { Cpu, History, Radio, Server, Activity, Plus, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
{
|
||||||
|
ssr: false,
|
||||||
import { getAdminUsers, startViewAs, resolveAgentLabel, getViewAsHeaders, type ManagedUser } from "@/lib/admin-api";
|
loading: () => (
|
||||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
<div className="h-[320px] w-full rounded-2xl border border-white/5 bg-slate-900/40 flex items-center justify-center">
|
||||||
import { usePollingKey } from "@/lib/usePolling";
|
<div className="flex flex-col items-center gap-2">
|
||||||
import { AgentModals } from "@/components/admin/AgentModals";
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
import ExternalAccountModal from "@/components/admin/ExternalAccountModal";
|
<span className="text-xs text-slate-400">Loading Agent Location Map...</span>
|
||||||
import { getAgentColumns, getExternalAccountColumns } from "./columns";
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default function AgentsPage() {
|
export default function AgentsPage() {
|
||||||
const router = useRouter();
|
const {
|
||||||
const [role, setRole] = useState<string | null>(null);
|
role,
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
agents,
|
||||||
const [uptimeMap, setUptimeMap] = useState<Record<string, number>>({});
|
uptimeMap,
|
||||||
const [storageMap, setStorageMap] = useState<Record<string, number>>({});
|
storageMap,
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
isLoading,
|
||||||
const [error, setError] = useState("");
|
error,
|
||||||
|
isCreateOpen,
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
setIsCreateOpen,
|
||||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
isDeleteOpen,
|
||||||
const [detailAgent, setDetailAgent] = useState<Agent | null>(null);
|
setIsDeleteOpen,
|
||||||
const [managedUsers, setManagedUsers] = useState<ManagedUser[]>([]);
|
detailAgent,
|
||||||
const [accountModalOpen, setAccountModalOpen] = useState(false);
|
setDetailAgent,
|
||||||
const [accountModalAgent, setAccountModalAgent] = useState<{ uuid: string; label: string } | null>(null);
|
managedUsers,
|
||||||
|
accountModalOpen,
|
||||||
const [externalModalOpen, setExternalModalOpen] = useState(false);
|
setAccountModalOpen,
|
||||||
const [externalModalUser, setExternalModalUser] = useState<ManagedUser | null>(null);
|
accountModalAgent,
|
||||||
|
setAccountModalAgent,
|
||||||
const [viewAsLoading, setViewAsLoading] = useState<string | null>(null);
|
externalModalOpen,
|
||||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
setExternalModalOpen,
|
||||||
const { timeRange } = useTimeFilter();
|
externalModalUser,
|
||||||
const refreshKey = usePollingKey(60000); // Auto-refresh every 60 seconds (1 minute)
|
setExternalModalUser,
|
||||||
|
targetSiteUuid,
|
||||||
const loadUptime = async () => {
|
setTargetSiteUuid,
|
||||||
try {
|
targetCreatedBy,
|
||||||
const res = await fetch(`/api/dashboard/agents/uptime?timeRange=${timeRange}`, {
|
setTargetCreatedBy,
|
||||||
headers: getViewAsHeaders()
|
locations,
|
||||||
});
|
isLocModalOpen,
|
||||||
if (res.ok) {
|
setIsLocModalOpen,
|
||||||
const json = await res.json();
|
locModalAgentUuid,
|
||||||
if (json.ok && json.uptime) setUptimeMap(json.uptime);
|
setLocModalAgentUuid,
|
||||||
}
|
locModalAgentLabel,
|
||||||
} catch (err) {
|
setLocModalAgentLabel,
|
||||||
console.error("Failed to load uptime stats:", err);
|
locModalCurrentLoc,
|
||||||
}
|
setLocModalCurrentLoc,
|
||||||
};
|
viewAsLoading,
|
||||||
|
selectedAgent,
|
||||||
const loadStorage = async () => {
|
loadAgents,
|
||||||
try {
|
loadLocations,
|
||||||
const res = await fetch('/api/dashboard/agents/storage', {
|
handleViewAs,
|
||||||
headers: getViewAsHeaders()
|
openDelete,
|
||||||
});
|
handleEditLocation,
|
||||||
if (res.ok) {
|
} = useAgentsData();
|
||||||
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 columns = getAgentColumns({
|
const columns = getAgentColumns({
|
||||||
role,
|
role,
|
||||||
@@ -158,6 +82,8 @@ export default function AgentsPage() {
|
|||||||
setAccountModalAgent,
|
setAccountModalAgent,
|
||||||
handleViewAs,
|
handleViewAs,
|
||||||
openDelete,
|
openDelete,
|
||||||
|
onEditLocation: handleEditLocation,
|
||||||
|
locations,
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalAgents = agents.length;
|
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 avgUptime = uptimeValues.length > 0 ? uptimeValues.reduce((s, v) => s + v, 0) / uptimeValues.length : 100;
|
||||||
|
|
||||||
const externalUsers = managedUsers.filter(u => u.role !== 'AGENT_VIEWER');
|
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) => {
|
const externalColumns = getExternalAccountColumns((user) => {
|
||||||
setExternalModalUser(user);
|
setExternalModalUser(user);
|
||||||
setExternalModalOpen(true);
|
setExternalModalOpen(true);
|
||||||
@@ -174,21 +104,24 @@ export default function AgentsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||||
<Server className="w-6 h-6 text-primary" />
|
<Server className="w-6 h-6 text-primary" />
|
||||||
Agents Inventory
|
Agents Inventory
|
||||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||||
</h2>
|
</h2>
|
||||||
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<HelpTrigger pageId="agents" />
|
||||||
onClick={() => setIsCreateOpen(true)}
|
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
|
||||||
className="flex items-center gap-2 bg-primary hover:bg-primary/95 text-primary-foreground px-4 py-2 rounded-xl text-sm font-semibold transition-all shadow-lg shadow-blue-500/10"
|
<button
|
||||||
>
|
onClick={() => setIsCreateOpen(true)}
|
||||||
<Plus className="w-4 h-4" />
|
className="flex items-center gap-2 bg-primary hover:bg-primary/95 text-primary-foreground px-4 py-2 rounded-xl text-sm font-semibold transition-all shadow-lg shadow-blue-500/10"
|
||||||
Provision Agent
|
>
|
||||||
</button>
|
<Plus className="w-4 h-4" />
|
||||||
)}
|
Provision Agent
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -197,111 +130,50 @@ export default function AgentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Historical Uptime Status Cards Grid */}
|
<UptimeStatsCards
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-xs font-semibold">
|
totalAgents={totalAgents}
|
||||||
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
|
onlineAgents={onlineAgents}
|
||||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
offlineAgents={offlineAgents}
|
||||||
<Server className="w-12 h-12 text-blue-400" />
|
avgUptime={avgUptime}
|
||||||
</div>
|
/>
|
||||||
<span className="text-slate-400 uppercase tracking-wider mb-1">Total Agents</span>
|
|
||||||
<span className="text-2xl font-black text-white tracking-tight">{totalAgents}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
|
|
||||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
|
||||||
<CheckCircle2 className="w-12 h-12 text-emerald-400" />
|
|
||||||
</div>
|
|
||||||
<span className="text-slate-400 uppercase tracking-wider mb-1">Online Agents</span>
|
|
||||||
<span className="text-2xl font-black text-emerald-400 tracking-tight">{onlineAgents}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
|
|
||||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
|
||||||
<XCircle className="w-12 h-12 text-red-400" />
|
|
||||||
</div>
|
|
||||||
<span className="text-slate-400 uppercase tracking-wider mb-1">Offline Agents</span>
|
|
||||||
<span className="text-2xl font-black text-red-400 tracking-tight">{offlineAgents}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
|
|
||||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
|
||||||
<Activity className="w-12 h-12 text-cyan-400" />
|
|
||||||
</div>
|
|
||||||
<span className="text-slate-400 uppercase tracking-wider mb-1">Avg. Historical Uptime</span>
|
|
||||||
<span className="text-2xl font-black text-cyan-400 tracking-tight">{avgUptime.toFixed(2)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
{/* Indonesia Agent Geolocation & Flow Map */}
|
||||||
<CardHeader>
|
{!isLoading && (
|
||||||
<CardTitle className="text-white">Provisioned Network Agents ({agents.length})</CardTitle>
|
<IndonesiaAgentMap
|
||||||
</CardHeader>
|
agents={agents}
|
||||||
<CardContent>
|
locations={locations}
|
||||||
{isLoading ? (
|
onEditLocation={handleEditLocation}
|
||||||
<div className="flex items-center justify-center py-12">
|
role={role}
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
data={agents}
|
|
||||||
columns={columns}
|
|
||||||
searchPlaceholder="Search agents..."
|
|
||||||
searchFilter={(row, query) => {
|
|
||||||
const q = query.toLowerCase();
|
|
||||||
return (
|
|
||||||
(row.uuid || "").toLowerCase().includes(q) ||
|
|
||||||
(row.serial || "").toLowerCase().includes(q) ||
|
|
||||||
(row.label || "").toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
|
|
||||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<CardTitle className="text-white">External Accounts Inventory ({externalUsers.length})</CardTitle>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setExternalModalUser(null); // Mode Create
|
|
||||||
setExternalModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" />
|
|
||||||
Add External Account
|
|
||||||
</button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
data={externalUsers}
|
|
||||||
columns={externalColumns}
|
|
||||||
searchPlaceholder="Search accounts..."
|
|
||||||
searchFilter={(row, query) => {
|
|
||||||
const q = query.toLowerCase();
|
|
||||||
return (
|
|
||||||
(row.username || "").toLowerCase().includes(q) ||
|
|
||||||
(row.account_name || "").toLowerCase().includes(q) ||
|
|
||||||
(row.role || "").toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AgentModals
|
<AgentsTableSection
|
||||||
|
agents={agents}
|
||||||
|
isLoading={isLoading}
|
||||||
|
columns={columns}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExternalAccountsSection
|
||||||
|
role={role}
|
||||||
|
isLoading={isLoading}
|
||||||
|
externalUsers={externalUsers}
|
||||||
|
superadminUsers={superadminUsers}
|
||||||
|
siabUsers={siabUsers}
|
||||||
|
nexusUsers={nexusUsers}
|
||||||
|
externalColumns={externalColumns}
|
||||||
|
setExternalModalUser={setExternalModalUser}
|
||||||
|
setTargetSiteUuid={setTargetSiteUuid}
|
||||||
|
setTargetCreatedBy={setTargetCreatedBy}
|
||||||
|
setExternalModalOpen={setExternalModalOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AgentsPageModals
|
||||||
isCreateOpen={isCreateOpen}
|
isCreateOpen={isCreateOpen}
|
||||||
setIsCreateOpen={setIsCreateOpen}
|
setIsCreateOpen={setIsCreateOpen}
|
||||||
isDeleteOpen={isDeleteOpen}
|
isDeleteOpen={isDeleteOpen}
|
||||||
setIsDeleteOpen={setIsDeleteOpen}
|
setIsDeleteOpen={setIsDeleteOpen}
|
||||||
selectedAgent={selectedAgent}
|
selectedAgent={selectedAgent}
|
||||||
onSuccess={loadAgents}
|
loadAgents={loadAgents}
|
||||||
detailAgent={detailAgent}
|
detailAgent={detailAgent}
|
||||||
setDetailAgent={setDetailAgent}
|
setDetailAgent={setDetailAgent}
|
||||||
accountModalOpen={accountModalOpen}
|
accountModalOpen={accountModalOpen}
|
||||||
@@ -309,13 +181,23 @@ export default function AgentsPage() {
|
|||||||
accountModalAgent={accountModalAgent}
|
accountModalAgent={accountModalAgent}
|
||||||
setAccountModalAgent={setAccountModalAgent}
|
setAccountModalAgent={setAccountModalAgent}
|
||||||
managedUsers={managedUsers}
|
managedUsers={managedUsers}
|
||||||
/>
|
externalModalOpen={externalModalOpen}
|
||||||
|
setExternalModalOpen={setExternalModalOpen}
|
||||||
<ExternalAccountModal
|
externalModalUser={externalModalUser}
|
||||||
isOpen={externalModalOpen}
|
setExternalModalUser={setExternalModalUser}
|
||||||
onClose={() => { setExternalModalOpen(false); setExternalModalUser(null); }}
|
targetSiteUuid={targetSiteUuid}
|
||||||
onSuccess={loadAgents}
|
setTargetSiteUuid={setTargetSiteUuid}
|
||||||
user={externalModalUser}
|
targetCreatedBy={targetCreatedBy}
|
||||||
|
setTargetCreatedBy={setTargetCreatedBy}
|
||||||
|
isLocModalOpen={isLocModalOpen}
|
||||||
|
setIsLocModalOpen={setIsLocModalOpen}
|
||||||
|
locModalAgentUuid={locModalAgentUuid}
|
||||||
|
setLocModalAgentUuid={setLocModalAgentUuid}
|
||||||
|
locModalAgentLabel={locModalAgentLabel}
|
||||||
|
setLocModalAgentLabel={setLocModalAgentLabel}
|
||||||
|
locModalCurrentLoc={locModalCurrentLoc}
|
||||||
|
setLocModalCurrentLoc={setLocModalCurrentLoc}
|
||||||
|
loadLocations={loadLocations}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<string | null>(null);
|
||||||
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
|
const [uptimeMap, setUptimeMap] = useState<Record<string, number>>({});
|
||||||
|
const [storageMap, setStorageMap] = useState<Record<string, number>>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
|
const [detailAgent, setDetailAgent] = useState<Agent | null>(null);
|
||||||
|
const [managedUsers, setManagedUsers] = useState<ManagedUser[]>([]);
|
||||||
|
const [accountModalOpen, setAccountModalOpen] = useState(false);
|
||||||
|
const [accountModalAgent, setAccountModalAgent] = useState<{ uuid: string; label: string } | null>(null);
|
||||||
|
|
||||||
|
const [externalModalOpen, setExternalModalOpen] = useState(false);
|
||||||
|
const [externalModalUser, setExternalModalUser] = useState<ManagedUser | null>(null);
|
||||||
|
const [targetSiteUuid, setTargetSiteUuid] = useState<string | null>(null);
|
||||||
|
const [targetCreatedBy, setTargetCreatedBy] = useState<string | null>(null);
|
||||||
|
const [selectedSite, setSelectedSite] = useState<string>("6681452d_9cae_4ff4_8ae8_0d504774265e");
|
||||||
|
|
||||||
|
const [locations, setLocations] = useState<any[]>([]);
|
||||||
|
const [isLocModalOpen, setIsLocModalOpen] = useState(false);
|
||||||
|
const [locModalAgentUuid, setLocModalAgentUuid] = useState("");
|
||||||
|
const [locModalAgentLabel, setLocModalAgentLabel] = useState("");
|
||||||
|
const [locModalCurrentLoc, setLocModalCurrentLoc] = useState<any | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const stored = localStorage.getItem('backone_site_uuid');
|
||||||
|
if (stored) setSelectedSite(stored);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [viewAsLoading, setViewAsLoading] = useState<string | null>(null);
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||||
|
const { timeRange } = useTimeFilter();
|
||||||
|
const refreshKey = usePollingKey(60000); // Auto-refresh every 60 seconds (1 minute)
|
||||||
|
|
||||||
|
const loadUptime = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/dashboard/agents/uptime?timeRange=${timeRange}`, {
|
||||||
|
headers: getViewAsHeaders()
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.ok && json.uptime) setUptimeMap(json.uptime);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load uptime stats:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStorage = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/dashboard/agents/storage', {
|
||||||
|
headers: getViewAsHeaders()
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.ok && json.storage) setStorageMap(json.storage);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load agent storage stats:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,12 +11,13 @@ import { Loader2, ChevronRight, Globe } from "lucide-react";
|
|||||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||||
import { fmtBytes, getAppFavicon, formatAppLabel } from "@/lib/utils";
|
import { fmtBytes, getAppFavicon, formatAppLabel } from "@/lib/utils";
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from "recharts";
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from "recharts";
|
||||||
|
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||||
|
import { getViewAsStatusSync } from "@/lib/admin-api";
|
||||||
|
|
||||||
export default function AppsPage() {
|
export default function AppsPage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
const { data, isLoading } = useTopApps(pageSize);
|
|
||||||
const [selectedApp, setSelectedApp] = useState<AppStat | null>(null);
|
const [selectedApp, setSelectedApp] = useState<AppStat | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -25,6 +26,10 @@ export default function AppsPage() {
|
|||||||
handleReset, activeCount
|
handleReset, activeCount
|
||||||
} = useUniversalFilterState();
|
} = useUniversalFilterState();
|
||||||
|
|
||||||
|
// Pass dateFrom/dateTo so the backend filters by the exact calendar date range
|
||||||
|
const { data, isLoading } = useTopApps(pageSize, dateFrom, dateTo);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -49,15 +54,13 @@ export default function AppsPage() {
|
|||||||
const filteredData = all.filter((row: any) => {
|
const filteredData = all.filter((row: any) => {
|
||||||
if (filters.app_label && filters.app_label !== "All" && row.app_label !== filters.app_label) return false;
|
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 (filters.category && filters.category !== "All" && row.category !== filters.category) return false;
|
||||||
|
// Date filtering is handled server-side via date_from/date_to params.
|
||||||
if (dateFrom || dateTo) {
|
// AppStat records do not have a last_seen field, so client-side date
|
||||||
const d = row.last_seen ? new Date(row.last_seen).toISOString().slice(0, 10) : "";
|
// filtering is intentionally omitted here.
|
||||||
if (dateFrom && d < dateFrom) return false;
|
|
||||||
if (dateTo && d > dateTo) return false;
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
let processedData = [...filteredData];
|
let processedData = [...filteredData];
|
||||||
const sortUpload = filters.sort_upload || "All";
|
const sortUpload = filters.sort_upload || "All";
|
||||||
const sortDownload = filters.sort_download || "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));
|
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) => {
|
// Use the data retention window (7 days) as the minimum selectable date.
|
||||||
if (r.last_seen) {
|
// Dynamic computation from loaded data causes a circular dependency where
|
||||||
const time = new Date(r.last_seen).getTime();
|
// the sidebar time filter (e.g., Last 24h) restricts the date picker range.
|
||||||
if (isFinite(time) && time < min) return time;
|
const minDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||||
}
|
|
||||||
return min;
|
|
||||||
}, Infinity);
|
|
||||||
const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01";
|
|
||||||
|
|
||||||
const top5 = processedData.slice(0, 5);
|
const top5 = processedData.slice(0, 5);
|
||||||
|
|
||||||
@@ -128,7 +127,7 @@ export default function AppsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||||
Applications
|
Applications
|
||||||
@@ -138,6 +137,7 @@ export default function AppsPage() {
|
|||||||
Click any application to see which agents and devices are using it.
|
Click any application to see which agents and devices are using it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<HelpTrigger pageId="apps" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UniversalFilters
|
<UniversalFilters
|
||||||
@@ -182,11 +182,7 @@ export default function AppsPage() {
|
|||||||
stroke="#8b949e"
|
stroke="#8b949e"
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => fmtBytes(value)}
|
||||||
const gb = value / 1073741824;
|
|
||||||
const mb = value / 1048576;
|
|
||||||
return gb >= 1 ? `${gb.toFixed(1)}GB` : `${mb.toFixed(0)}MB`;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<RechartsTooltip
|
<RechartsTooltip
|
||||||
cursor={{ fill: "#21262d" }}
|
cursor={{ fill: "#21262d" }}
|
||||||
@@ -227,6 +223,7 @@ export default function AppsPage() {
|
|||||||
appLabel={selectedApp.app_label}
|
appLabel={selectedApp.app_label}
|
||||||
favicon={selectedApp.favicon}
|
favicon={selectedApp.favicon}
|
||||||
category={selectedApp.category}
|
category={selectedApp.category}
|
||||||
|
agentUuid={getViewAsStatusSync().agent_uuid}
|
||||||
onClose={() => setSelectedApp(null)}
|
onClose={() => setSelectedApp(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import { fmtBytes } from "@/lib/utils";
|
|||||||
import { Loader2, ChevronRight, ChevronDown } from "lucide-react";
|
import { Loader2, ChevronRight, ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||||
|
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||||
|
|
||||||
export default function DevicesPage() {
|
export default function DevicesPage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const devices = useDevices(5000);
|
|
||||||
const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null);
|
const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -23,6 +23,9 @@ export default function DevicesPage() {
|
|||||||
handleReset, activeCount
|
handleReset, activeCount
|
||||||
} = useUniversalFilterState();
|
} = useUniversalFilterState();
|
||||||
|
|
||||||
|
// Pass dateFrom/dateTo so the backend filters by the exact calendar date range
|
||||||
|
const devices = useDevices(5000, dateFrom, dateTo);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
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.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.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 (filters.manufacturer && filters.manufacturer !== "All" && row.manufacturer !== filters.manufacturer) return false;
|
||||||
|
// Date filtering is handled server-side via date_from/date_to on timestamp.
|
||||||
if (dateFrom || dateTo) {
|
// last_seen (DPI network activity time) != timestamp (collection cycle time),
|
||||||
const d = row.last_seen ? new Date(row.last_seen).toISOString().slice(0, 10) : "";
|
// so client-side date filtering would incorrectly exclude valid records.
|
||||||
if (dateFrom && d < dateFrom) return false;
|
|
||||||
if (dateTo && d > dateTo) return false;
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
let processedData = [...filteredData];
|
let processedData = [...filteredData];
|
||||||
const sortUpload = filters.sort_upload || "All";
|
const sortUpload = filters.sort_upload || "All";
|
||||||
const sortDownload = filters.sort_download || "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);
|
processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download);
|
||||||
}
|
}
|
||||||
|
|
||||||
const minTime = all.reduce((min: number, r: any) => {
|
// Use the data retention window (7 days) as the minimum selectable date.
|
||||||
if (r.last_seen) {
|
// Do NOT compute minDate from loaded data — the loaded data is already filtered
|
||||||
const time = new Date(r.last_seen).getTime();
|
// by the sidebar time range (e.g., last 24h), which would incorrectly restrict
|
||||||
if (isFinite(time) && time < min) return time;
|
// 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 min;
|
|
||||||
}, Infinity);
|
|
||||||
const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in pb-10">
|
<div className="space-y-8 animate-fade-in pb-10">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||||
Devices
|
Devices
|
||||||
@@ -171,6 +169,7 @@ export default function DevicesPage() {
|
|||||||
<span className="text-slate-500 text-xs">Click an IP to see app usage & flows.</span>
|
<span className="text-slate-500 text-xs">Click an IP to see app usage & flows.</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<HelpTrigger pageId="devices" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UniversalFilters
|
<UniversalFilters
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DataTable, Column } from "@/components/ui/DataTable";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||||
|
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||||
|
|
||||||
export default function DNSPage() {
|
export default function DNSPage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
@@ -95,11 +96,12 @@ export default function DNSPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||||
DNS Intelligence
|
DNS Intelligence
|
||||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||||
</h2>
|
</h2>
|
||||||
|
<HelpTrigger pageId="dns" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UniversalFilters
|
<UniversalFilters
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { MetadataDetailPanel } from "@/components/dpi/MetadataDetailPanel";
|
|||||||
import { MetadataDetailType } from "@/lib/api-metadata-detail";
|
import { MetadataDetailType } from "@/lib/api-metadata-detail";
|
||||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||||
import { fmtBytes, getKnownDomainService } from "@/lib/utils";
|
import { fmtBytes, getKnownDomainService } from "@/lib/utils";
|
||||||
|
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||||
|
|
||||||
// Clickable hint badge shown in table column headers when rows are interactive
|
// Clickable hint badge shown in table column headers when rows are interactive
|
||||||
function ClickHint() {
|
function ClickHint() {
|
||||||
@@ -59,12 +60,15 @@ export default function DpiAnalyticsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h1 className="text-3xl font-bold text-white tracking-tight">BackOne Metadata</h1>
|
<div>
|
||||||
<p className="text-slate-400 mt-2">
|
<h1 className="text-3xl font-bold text-white tracking-tight">DPI MetaData</h1>
|
||||||
Deep Packet Inspection metadata and fingerprinting.{" "}
|
<p className="text-slate-400 mt-2">
|
||||||
<span className="text-blue-400 text-sm">Click any row to view device-level detail.</span>
|
Deep Packet Inspection metadata and fingerprinting.{" "}
|
||||||
</p>
|
<span className="text-blue-400 text-sm">Click any row to view device-level detail.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<HelpTrigger pageId="dpi-analytics" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs>
|
<Tabs>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
|||||||
import { Loader2, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
import { Loader2, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
||||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||||
|
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
@@ -18,7 +19,6 @@ function EventsContent() {
|
|||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [selectedIp, setSelectedIp] = useState<string | null>(null);
|
const [selectedIp, setSelectedIp] = useState<string | null>(null);
|
||||||
const [selectedMac, setSelectedMac] = useState<string | null>(null);
|
const [selectedMac, setSelectedMac] = useState<string | null>(null);
|
||||||
const { data, isLoading } = useEvents();
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const highlightId = searchParams.get("highlight");
|
const highlightId = searchParams.get("highlight");
|
||||||
|
|
||||||
@@ -28,6 +28,9 @@ function EventsContent() {
|
|||||||
handleReset, activeCount
|
handleReset, activeCount
|
||||||
} = useUniversalFilterState();
|
} = useUniversalFilterState();
|
||||||
|
|
||||||
|
// Pass dateFrom/dateTo so the backend filters by the exact calendar date range
|
||||||
|
const { data, isLoading } = useEvents(0, dateFrom, dateTo);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -62,14 +65,10 @@ function EventsContent() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const minTime = all.reduce((min: number, r: any) => {
|
// Use the data retention window (7 days) as the minimum selectable date.
|
||||||
if (r.timestamp) {
|
// Dynamic computation from loaded data causes a circular dependency where
|
||||||
const time = new Date(r.timestamp).getTime();
|
// the sidebar time filter (e.g., Last 24h) restricts the date picker range.
|
||||||
if (isFinite(time) && time < min) return time;
|
const minDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||||
}
|
|
||||||
return min;
|
|
||||||
}, Infinity);
|
|
||||||
const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01";
|
|
||||||
|
|
||||||
const getSeverityIcon = (severity: string) => {
|
const getSeverityIcon = (severity: string) => {
|
||||||
switch (severity.toLowerCase()) {
|
switch (severity.toLowerCase()) {
|
||||||
@@ -157,11 +156,12 @@ function EventsContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||||
Network Events
|
Network Events
|
||||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||||
</h2>
|
</h2>
|
||||||
|
<HelpTrigger pageId="events" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UniversalFilters
|
<UniversalFilters
|
||||||
@@ -223,4 +223,3 @@ export default function EventsPage() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export { EventsContent };
|
|
||||||
@@ -10,6 +10,7 @@ import { fmtBytes, formatAppLabel } from "@/lib/utils";
|
|||||||
import { IpDetails } from "@/components/ui/IpDetails";
|
import { IpDetails } from "@/components/ui/IpDetails";
|
||||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||||
|
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||||
|
|
||||||
function explainAppOrPort(appLabel: string | null, domain: string | null, port: number) {
|
function explainAppOrPort(appLabel: string | null, domain: string | null, port: number) {
|
||||||
const label = appLabel || domain || "";
|
const label = appLabel || domain || "";
|
||||||
@@ -61,14 +62,16 @@ function explainAppOrPort(appLabel: string | null, domain: string | null, port:
|
|||||||
export default function FlowsPage() {
|
export default function FlowsPage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [selectedIp, setSelectedIp] = useState<string | null>(null);
|
const [selectedIp, setSelectedIp] = useState<string | null>(null);
|
||||||
const { data, isLoading } = useFlows();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
filters, dateFrom, dateTo,
|
filters, dateFrom, dateTo,
|
||||||
handleFilterChange, setDateFrom, setDateTo,
|
handleFilterChange, setDateFrom, setDateTo,
|
||||||
handleReset, activeCount
|
handleReset, activeCount
|
||||||
} = useUniversalFilterState();
|
} = useUniversalFilterState();
|
||||||
|
|
||||||
|
// Pass dateFrom/dateTo so the backend filters by the exact calendar date range
|
||||||
|
const { data, isLoading } = useFlows(5000, dateFrom, dateTo);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -77,6 +80,7 @@ export default function FlowsPage() {
|
|||||||
|
|
||||||
const all = data || [];
|
const all = data || [];
|
||||||
|
|
||||||
|
|
||||||
// Extract unique options
|
// Extract unique options
|
||||||
const opts = (key: string) => {
|
const opts = (key: string) => {
|
||||||
const raw = Array.from(new Set(all.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
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.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.app && filters.app !== "All" && row.app_label !== filters.app) return false;
|
||||||
if (filters.domain && filters.domain !== "All" && row.domain !== filters.domain) return false;
|
if (filters.domain && filters.domain !== "All" && row.domain !== filters.domain) return false;
|
||||||
|
// Date filtering is handled server-side via date_from/date_to on timestamp.
|
||||||
if (dateFrom || dateTo) {
|
// last_seen != timestamp (a flow collected July 13 can have last_seen=July 14)
|
||||||
const d = row.last_seen ? new Date(row.last_seen).toISOString().slice(0, 10) : "";
|
// so client-side filtering by last_seen would incorrectly exclude valid records.
|
||||||
if (dateFrom && d < dateFrom) return false;
|
|
||||||
if (dateTo && d > dateTo) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
let processedData = [...filteredData];
|
let processedData = [...filteredData];
|
||||||
const sortUpload = filters.sort_upload || "All";
|
const sortUpload = filters.sort_upload || "All";
|
||||||
const sortDownload = filters.sort_download || "All";
|
const sortDownload = filters.sort_download || "All";
|
||||||
@@ -127,14 +128,10 @@ export default function FlowsPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const minTime = all.reduce((min: number, r: any) => {
|
// Use the data retention window (7 days) as the minimum selectable date.
|
||||||
if (r.last_seen) {
|
// Dynamic computation from loaded data causes a circular dependency where
|
||||||
const time = new Date(r.last_seen).getTime();
|
// the sidebar time filter (e.g., Last 24h) restricts the date picker range.
|
||||||
if (isFinite(time) && time < min) return time;
|
const minDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||||
}
|
|
||||||
return min;
|
|
||||||
}, Infinity);
|
|
||||||
const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01";
|
|
||||||
|
|
||||||
const columns: Column<typeof data[0]>[] = [
|
const columns: Column<typeof data[0]>[] = [
|
||||||
{
|
{
|
||||||
@@ -207,11 +204,12 @@ export default function FlowsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||||
Active Flows
|
Active Flows
|
||||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||||
</h2>
|
</h2>
|
||||||
|
<HelpTrigger pageId="flows" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UniversalFilters
|
<UniversalFilters
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Loader2 } from "lucide-react";
|
|||||||
import { fmtBytes } from "@/lib/utils";
|
import { fmtBytes } from "@/lib/utils";
|
||||||
import { WorldMap } from "@/components/ui/WorldMap";
|
import { WorldMap } from "@/components/ui/WorldMap";
|
||||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||||
|
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||||
|
|
||||||
export default function GeographyPage() {
|
export default function GeographyPage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
@@ -102,7 +103,7 @@ export default function GeographyPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in pb-10">
|
<div className="space-y-8 animate-fade-in pb-10">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||||
Geographic Traffic
|
Geographic Traffic
|
||||||
@@ -110,6 +111,7 @@ export default function GeographyPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground mt-1">Visualize data flow and activity across the globe.</p>
|
<p className="text-muted-foreground mt-1">Visualize data flow and activity across the globe.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<HelpTrigger pageId="geography" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UniversalFilters
|
<UniversalFilters
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Loader2, Bitcoin, Shield, Server, Lock, AlertTriangle, Key, Globe, Netw
|
|||||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||||
import { fmtBytes } from "@/lib/utils";
|
import { fmtBytes } from "@/lib/utils";
|
||||||
|
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||||
|
|
||||||
export default function IntelligencePage() {
|
export default function IntelligencePage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
@@ -370,11 +371,12 @@ export default function IntelligencePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||||
Threat Intelligence Feeds
|
Threat Intelligence Feeds
|
||||||
{loadingStats && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
{loadingStats && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||||
</h2>
|
</h2>
|
||||||
|
<HelpTrigger pageId="intelligence" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DashboardLayout } from "@/components/layout/DashboardLayout";
|
import { DashboardLayout } from "@/components/layout/DashboardLayout";
|
||||||
import { TimeFilterProvider } from "@/contexts/TimeFilterContext";
|
import { TimeFilterProvider } from "@/contexts/TimeFilterContext";
|
||||||
|
import { TenantConfigProvider } from "@/contexts/TenantConfigContext";
|
||||||
|
|
||||||
export default function AppLayout({
|
export default function AppLayout({
|
||||||
children,
|
children,
|
||||||
@@ -8,7 +9,9 @@ export default function AppLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<TimeFilterProvider>
|
<TimeFilterProvider>
|
||||||
<DashboardLayout>{children}</DashboardLayout>
|
<TenantConfigProvider>
|
||||||
|
<DashboardLayout>{children}</DashboardLayout>
|
||||||
|
</TenantConfigProvider>
|
||||||
</TimeFilterProvider>
|
</TimeFilterProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,359 +1,44 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useLookupApplications, useLookupCategories, LookupApp } from "@/lib/api";
|
import { Tabs, Tab } from "@/components/ui/Tabs";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||||
import { DropdownFilterSelect } from "@/components/ui/DropdownFilterSelect";
|
import { ApplicationCatalog } from "@/components/lookup/ApplicationCatalog";
|
||||||
import { Loader2, Search, AppWindow, HelpCircle, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, X, Info, Tag, Box, Shield, Server } from "lucide-react";
|
import { BlacklistConfiguration } from "@/components/lookup/BlacklistConfiguration";
|
||||||
|
|
||||||
export default function LookupPage() {
|
export default function LookupPage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState("");
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [selectedApp, setSelectedApp] = useState<LookupApp | null>(null);
|
|
||||||
const [isClosingModal, setIsClosingModal] = useState(false);
|
|
||||||
const limit = 24; // 24 items per page fits a 2, 3, or 4 column grid perfectly
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
|
||||||
setIsClosingModal(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setSelectedApp(null);
|
|
||||||
setIsClosingModal(false);
|
|
||||||
}, 480);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Debounce search query input to avoid spamming requests
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = setTimeout(() => {
|
|
||||||
setDebouncedSearch(searchQuery);
|
|
||||||
setPage(1); // Reset page on new search
|
|
||||||
}, 400);
|
|
||||||
|
|
||||||
return () => clearTimeout(handler);
|
|
||||||
}, [searchQuery]);
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useLookupApplications(page, limit, debouncedSearch, selectedCategory);
|
|
||||||
const { data: categories, error: categoriesError } = useLookupCategories();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (categoriesError) {
|
|
||||||
console.error("Failed to fetch categories:", categoriesError);
|
|
||||||
}
|
|
||||||
}, [categoriesError]);
|
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
const applications = data?.applications || [];
|
|
||||||
const pagination = data?.pagination || { total_records: 0, total_pages: 1, current_page: 1 };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in pb-10">
|
<div className="space-y-6 animate-fade-in pb-10">
|
||||||
<div>
|
{/* Page Header */}
|
||||||
<h1 className="text-3xl font-bold text-white tracking-tight flex items-center gap-3">
|
<div className="flex items-center justify-between gap-4">
|
||||||
Application Database
|
<div>
|
||||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
<h1 className="text-3xl font-bold text-white tracking-tight">
|
||||||
</h1>
|
Application Database
|
||||||
<p className="text-slate-400 mt-2">
|
</h1>
|
||||||
Search BackOne's extensive classification database of over 2,500+ apps, protocols, and services.
|
<p className="text-slate-400 mt-2">
|
||||||
</p>
|
Search application lookup catalog and configure traffic blacklist policy rules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<HelpTrigger pageId="lookup" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search & Filter Bar */}
|
{/* Tabs Container */}
|
||||||
<div className="flex flex-col sm:flex-row gap-4 animate-slide-up relative z-50" style={{ animationDelay: '50ms' }}>
|
<Tabs>
|
||||||
<div className="relative flex-1 max-w-md w-full">
|
<Tab label="Application Catalog">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
|
<ApplicationCatalog />
|
||||||
<input
|
</Tab>
|
||||||
type="text"
|
<Tab label="Blacklist Configuration">
|
||||||
placeholder="Search by app name or tag (e.g. facebook, openvpn)..."
|
<BlacklistConfiguration />
|
||||||
className="w-full bg-slate-900 border border-border/50 rounded-lg pl-10 pr-4 py-2 text-sm text-white placeholder-slate-400 focus:outline-none hover:border-slate-700 transition-all duration-200"
|
</Tab>
|
||||||
value={searchQuery}
|
</Tabs>
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative w-full sm:w-64 z-50">
|
|
||||||
<DropdownFilterSelect
|
|
||||||
value={selectedCategory || "All"}
|
|
||||||
onChange={(val) => {
|
|
||||||
setSelectedCategory(val === "All" ? "" : val);
|
|
||||||
setPage(1); // Reset page on category change
|
|
||||||
}}
|
|
||||||
options={categories || []}
|
|
||||||
placeholder="All Categories"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cards Grid */}
|
|
||||||
{error ? (
|
|
||||||
<div className="p-8 bg-red-950/20 border border-red-500/30 rounded-xl text-center text-red-400">
|
|
||||||
<p className="font-semibold">Failed to load application lookup database</p>
|
|
||||||
<p className="text-sm mt-1">{error.message}</p>
|
|
||||||
</div>
|
|
||||||
) : applications.length === 0 && !isLoading ? (
|
|
||||||
<div className="p-16 bg-card/40 border border-border/50 rounded-2xl text-center text-slate-400 max-w-lg mx-auto">
|
|
||||||
<AppWindow className="h-12 w-12 mx-auto mb-4 text-slate-500" />
|
|
||||||
<p className="font-semibold text-white">No applications found</p>
|
|
||||||
<p className="text-sm mt-1">Try refining your search keyword or clearing the input.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 animate-slide-up" style={{ animationDelay: '100ms' }}>
|
|
||||||
{applications.map((app: LookupApp) => (
|
|
||||||
<Card
|
|
||||||
key={app.id}
|
|
||||||
onClick={() => setSelectedApp(app)}
|
|
||||||
className="group relative overflow-hidden bg-card/60 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-primary/40 transition-all duration-300 flex flex-col justify-between cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="p-5 flex flex-col h-full justify-between gap-4">
|
|
||||||
{/* Top: Icon + Title */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="h-10 w-10 rounded-lg bg-secondary/80 flex items-center justify-center overflow-hidden border border-border/40 group-hover:border-primary/20 transition-all">
|
|
||||||
{app.favicon || app.logo ? (
|
|
||||||
<>
|
|
||||||
<img
|
|
||||||
src={app.favicon || app.logo}
|
|
||||||
alt={app.label}
|
|
||||||
className="h-6 w-6 object-contain"
|
|
||||||
onError={(e) => {
|
|
||||||
// If image fails, hide it and show the sibling icon
|
|
||||||
const img = e.target as HTMLElement;
|
|
||||||
img.style.display = 'none';
|
|
||||||
const sibling = img.nextSibling as HTMLElement;
|
|
||||||
if (sibling) {
|
|
||||||
sibling.style.display = 'block';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<AppWindow className="h-5 w-5 text-slate-400" style={{ display: 'none' }} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<AppWindow className="h-5 w-5 text-slate-400" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] uppercase font-semibold tracking-wider text-primary bg-primary/10 border border-primary/20 px-2 py-0.5 rounded-full">
|
|
||||||
{app.application_category?.label || "Unclassified"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="font-bold text-white group-hover:text-primary transition-colors text-base line-clamp-1">
|
|
||||||
{app.label}
|
|
||||||
</h3>
|
|
||||||
<p className="text-[11px] text-slate-500 font-mono mt-0.5">{app.tag}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-slate-400 line-clamp-3 leading-relaxed mt-2">
|
|
||||||
{app.description || "No description provided for this application definition."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom: ID and system information */}
|
|
||||||
<div className="flex items-center justify-between pt-2 border-t border-border/30 text-[10px] text-slate-500 font-mono">
|
|
||||||
<span>ID: {app.id}</span>
|
|
||||||
<span>{app.name || "N/A"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination Controls */}
|
|
||||||
{pagination.total_pages > 1 && (
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 pt-4 border-t border-border/50 text-slate-400 text-sm animate-slide-up" style={{ animationDelay: '150ms' }}>
|
|
||||||
<div>
|
|
||||||
Showing <span className="text-white font-medium">{((page - 1) * limit) + 1}</span> to{" "}
|
|
||||||
<span className="text-white font-medium">
|
|
||||||
{Math.min(page * limit, pagination.total_records)}
|
|
||||||
</span>{" "}
|
|
||||||
of <span className="text-white font-medium">{pagination.total_records}</span> entries
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{/* First Page */}
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(1)}
|
|
||||||
disabled={page === 1 || isLoading}
|
|
||||||
className="p-2 rounded-lg bg-card border border-border/50 text-slate-400 hover:bg-secondary disabled:opacity-30 disabled:hover:bg-card transition-colors"
|
|
||||||
title="First Page"
|
|
||||||
>
|
|
||||||
<ChevronsLeft className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Prev Page */}
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(p => Math.max(p - 1, 1))}
|
|
||||||
disabled={page === 1 || isLoading}
|
|
||||||
className="p-2 rounded-lg bg-card border border-border/50 text-slate-400 hover:bg-secondary disabled:opacity-30 disabled:hover:bg-card transition-colors"
|
|
||||||
title="Previous Page"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Page indicators */}
|
|
||||||
<span className="px-4 py-1.5 rounded-lg bg-secondary/50 border border-border/30 text-white font-mono">
|
|
||||||
{page} / {pagination.total_pages}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Next Page */}
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(p => Math.min(p + 1, pagination.total_pages))}
|
|
||||||
disabled={page === pagination.total_pages || isLoading}
|
|
||||||
className="p-2 rounded-lg bg-card border border-border/50 text-slate-400 hover:bg-secondary disabled:opacity-30 disabled:hover:bg-card transition-colors"
|
|
||||||
title="Next Page"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Last Page */}
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(pagination.total_pages)}
|
|
||||||
disabled={page === pagination.total_pages || isLoading}
|
|
||||||
className="p-2 rounded-lg bg-card border border-border/50 text-slate-400 hover:bg-secondary disabled:opacity-30 disabled:hover:bg-card transition-colors"
|
|
||||||
title="Last Page"
|
|
||||||
>
|
|
||||||
<ChevronsRight className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Application Detail Modal */}
|
|
||||||
{selectedApp && (
|
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6" onClick={handleCloseModal}>
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosingModal ? 'animate-overlay-out' : 'animate-overlay-in'}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`relative w-full max-w-2xl bg-card border border-border rounded-2xl shadow-2xl shadow-black/60 overflow-hidden ${isClosingModal ? 'animate-modal-out' : 'animate-modal-in'}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
|
|
||||||
{/* Header Background */}
|
|
||||||
<div className="h-32 bg-gradient-to-br from-primary/20 via-primary/5 to-transparent relative">
|
|
||||||
<button
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
className="absolute top-4 right-4 p-2 bg-black/20 hover:bg-black/40 rounded-full text-white/70 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="px-6 pb-6 sm:px-8 sm:pb-8 relative -mt-16">
|
|
||||||
|
|
||||||
{/* Icon/Logo */}
|
|
||||||
<div className="w-24 h-24 bg-card rounded-2xl border-4 border-card flex items-center justify-center overflow-hidden shadow-xl relative z-10 mb-4 bg-secondary">
|
|
||||||
{selectedApp.logo || selectedApp.favicon ? (
|
|
||||||
<img
|
|
||||||
src={selectedApp.logo || selectedApp.favicon}
|
|
||||||
alt={selectedApp.label}
|
|
||||||
className="w-16 h-16 object-contain"
|
|
||||||
onError={(e) => {
|
|
||||||
const img = e.target as HTMLElement;
|
|
||||||
img.style.display = 'none';
|
|
||||||
const sibling = img.nextSibling as HTMLElement;
|
|
||||||
if (sibling) sibling.style.display = 'block';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<AppWindow className="w-12 h-12 text-slate-400" style={{ display: (selectedApp.logo || selectedApp.favicon) ? 'none' : 'block' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title & Category */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
<h2 className="text-2xl sm:text-3xl font-bold text-white tracking-tight">{selectedApp.label}</h2>
|
|
||||||
{selectedApp.application_category?.label && (
|
|
||||||
<span className="px-3 py-1 bg-primary/10 border border-primary/20 text-primary text-xs font-bold uppercase tracking-widest rounded-full">
|
|
||||||
{selectedApp.application_category.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{selectedApp.full_name && selectedApp.full_name !== selectedApp.label && (
|
|
||||||
<p className="text-slate-400 font-medium">{selectedApp.full_name}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="space-y-4 md:col-span-2">
|
|
||||||
<div className="flex items-start gap-3 text-slate-300 bg-secondary/30 p-4 rounded-xl border border-border/30">
|
|
||||||
<Info className="w-5 h-5 text-primary shrink-0 mt-0.5" />
|
|
||||||
<p className="text-sm leading-relaxed">
|
|
||||||
{selectedApp.description || "No detailed description provided for this application or service."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metadata Info */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="text-xs uppercase font-bold text-slate-500 tracking-wider">System Metadata</h3>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
|
||||||
<div className="flex items-center gap-2 text-slate-400">
|
|
||||||
<Box className="w-4 h-4" />
|
|
||||||
<span className="text-sm">App ID</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.id}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
|
||||||
<div className="flex items-center gap-2 text-slate-400">
|
|
||||||
<Tag className="w-4 h-4" />
|
|
||||||
<span className="text-sm">System Name</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.name || 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
|
||||||
<div className="flex items-center gap-2 text-slate-400">
|
|
||||||
<Shield className="w-4 h-4" />
|
|
||||||
<span className="text-sm">Tag / Alias</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.tag || 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Application Category Detail */}
|
|
||||||
{selectedApp.application_category && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="text-xs uppercase font-bold text-slate-500 tracking-wider">Category Info</h3>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
|
||||||
<div className="flex items-center gap-2 text-slate-400">
|
|
||||||
<Server className="w-4 h-4" />
|
|
||||||
<span className="text-sm">Category ID</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.application_category.id}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
|
||||||
<div className="flex items-center gap-2 text-slate-400">
|
|
||||||
<Info className="w-4 h-4" />
|
|
||||||
<span className="text-sm">Category Tag</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.application_category.tag}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@/lib/api-advanced";
|
} from "@/lib/api-advanced";
|
||||||
import { IpDetails } from "@/components/ui/IpDetails";
|
import { IpDetails } from "@/components/ui/IpDetails";
|
||||||
import { RemoteIpDetailModal } from "@/components/ui/RemoteIpDetailModal";
|
import { RemoteIpDetailModal } from "@/components/ui/RemoteIpDetailModal";
|
||||||
|
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||||
|
|
||||||
export default function NetworkInfrastructurePage() {
|
export default function NetworkInfrastructurePage() {
|
||||||
const [selectedRemoteIp, setSelectedRemoteIp] = useState<string | null>(null);
|
const [selectedRemoteIp, setSelectedRemoteIp] = useState<string | null>(null);
|
||||||
@@ -42,9 +43,12 @@ export default function NetworkInfrastructurePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h1 className="text-3xl font-bold text-white tracking-tight">Network Infrastructure</h1>
|
<div>
|
||||||
<p className="text-slate-400 mt-2">Physical, logical, and geographic network topologies.</p>
|
<h1 className="text-3xl font-bold text-white tracking-tight">Network Infrastructure</h1>
|
||||||
|
<p className="text-slate-400 mt-2">Physical, logical, and geographic network topologies.</p>
|
||||||
|
</div>
|
||||||
|
<HelpTrigger pageId="network-infrastructure" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs>
|
<Tabs>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useAppCategories, useContinents } from "@/lib/api-with-context";
|
|||||||
import { fmtBytes } from "@/lib/utils";
|
import { fmtBytes } from "@/lib/utils";
|
||||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||||
import { CategoryDetailPanel } from "@/components/network/CategoryDetailPanel";
|
import { CategoryDetailPanel } from "@/components/network/CategoryDetailPanel";
|
||||||
|
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||||
|
|
||||||
const COLORS = ['#58a6ff', '#3fb950', '#f0883e', '#bc8cff', '#f85149'];
|
const COLORS = ['#58a6ff', '#3fb950', '#f0883e', '#bc8cff', '#f85149'];
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ export default function NetworkIntelligencePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in pb-10">
|
<div className="space-y-8 animate-fade-in pb-10">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||||
Traffic Categories
|
Traffic Categories
|
||||||
@@ -90,6 +91,7 @@ export default function NetworkIntelligencePage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground mt-1">Deep packet inspection analytics and geography.</p>
|
<p className="text-muted-foreground mt-1">Deep packet inspection analytics and geography.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<HelpTrigger pageId="network-intelligence" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UniversalFilters
|
<UniversalFilters
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { GlobeMap } from "@/components/ui/GlobeMap";
|
|||||||
import { getOriginLocation, getDestinationLocation } from "@/lib/geoUtils";
|
import { getOriginLocation, getDestinationLocation } from "@/lib/geoUtils";
|
||||||
import { KPICards } from "@/components/dashboard/KPICards";
|
import { KPICards } from "@/components/dashboard/KPICards";
|
||||||
import { TopWidgets } from "@/components/dashboard/TopWidgets";
|
import { TopWidgets } from "@/components/dashboard/TopWidgets";
|
||||||
|
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||||
|
|
||||||
export default function SummaryPage() {
|
export default function SummaryPage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
@@ -82,7 +83,7 @@ export default function SummaryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in pb-10">
|
<div className="space-y-8 animate-fade-in pb-10">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||||
Summary Overview
|
Summary Overview
|
||||||
@@ -90,6 +91,7 @@ export default function SummaryPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground mt-1">Real-time network traffic and intelligence summary.</p>
|
<p className="text-muted-foreground mt-1">Real-time network traffic and intelligence summary.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<HelpTrigger pageId="overview" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import TlsVersionsChart from "@/components/security-audit/TlsVersionsChart";
|
|||||||
import TlsTables from "@/components/security-audit/TlsTables";
|
import TlsTables from "@/components/security-audit/TlsTables";
|
||||||
import SslSanTable from "@/components/security-audit/SslSanTable";
|
import SslSanTable from "@/components/security-audit/SslSanTable";
|
||||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||||
|
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||||
|
|
||||||
export default function SecurityAuditPage() {
|
export default function SecurityAuditPage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
@@ -53,11 +54,14 @@ export default function SecurityAuditPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h2 className="text-2xl font-bold text-white tracking-tight">Security & Encryption Audit</h2>
|
<div>
|
||||||
<p className="text-sm text-slate-400 mt-1">
|
<h2 className="text-2xl font-bold text-white tracking-tight">Security & Encryption Audit</h2>
|
||||||
Monitor TLS versions distribution, cipher suites, active certificate properties, and device risk profiles.
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
</p>
|
Monitor TLS versions distribution, cipher suites, active certificate properties, and device risk profiles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<HelpTrigger pageId="security-audit" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UniversalFilters
|
<UniversalFilters
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ThreatFilters } from "@/components/threats/ThreatFilters";
|
|||||||
import { ThreatRecommendationsDrawer } from "@/components/threats/ThreatRecommendationsDrawer";
|
import { ThreatRecommendationsDrawer } from "@/components/threats/ThreatRecommendationsDrawer";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { formatAppLabel } from "@/lib/utils";
|
import { formatAppLabel } from "@/lib/utils";
|
||||||
|
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||||
|
|
||||||
// ─── ThreatsContent ───────────────────────────────────────────────────────────
|
// ─── ThreatsContent ───────────────────────────────────────────────────────────
|
||||||
function ThreatsContent() {
|
function ThreatsContent() {
|
||||||
@@ -212,7 +213,7 @@ function ThreatsContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in pb-10">
|
<div className="space-y-8 animate-fade-in pb-10">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||||
Threat Intelligence
|
Threat Intelligence
|
||||||
@@ -220,9 +221,12 @@ function ThreatsContent() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground mt-1">Monitor and respond to network security threats.</p>
|
<p className="text-muted-foreground mt-1">Monitor and respond to network security threats.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-4 py-2 rounded-lg shadow-[0_0_15px_rgba(16,185,129,0.1)]">
|
<div className="flex items-center gap-3">
|
||||||
<ShieldCheck className="h-4 w-4" />
|
<HelpTrigger pageId="intelligence" />
|
||||||
<span>Active Monitoring</span>
|
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-4 py-2 rounded-lg shadow-[0_0_15px_rgba(16,185,129,0.1)]">
|
||||||
|
<ShieldCheck className="h-4 w-4" />
|
||||||
|
<span>Active Monitoring</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 <HelpPageContent />;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Container */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0, y: 15 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0, y: 15 }}
|
||||||
|
className="relative w-full max-w-md overflow-hidden rounded-2xl border border-white/10 bg-slate-900/90 p-6 shadow-2xl backdrop-blur-xl"
|
||||||
|
>
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-primary/25 p-2 text-primary">
|
||||||
|
<MapPin className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white">Agent Geolocation</h3>
|
||||||
|
<p className="text-xs text-slate-400">Configure coordinates for <span className="text-primary font-medium">{agentLabel}</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error / Success Alerts */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 flex items-start gap-2.5 rounded-lg border border-red-500/10 bg-red-500/5 p-3 text-xs text-red-400">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="mb-4 flex items-start gap-2.5 rounded-lg border border-green-500/10 bg-green-500/5 p-3 text-xs text-green-400">
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
|
<span>{success}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-semibold text-slate-300">Latitude</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={latitude}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-semibold text-slate-300">Longitude</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={longitude}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-semibold text-slate-300">Location Label (Optional)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="mt-6 flex justify-between gap-3 pt-2">
|
||||||
|
{currentLocation ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={isResetting || isSubmitting}
|
||||||
|
className="flex items-center gap-1.5 rounded-xl border border-white/5 bg-white/[0.02] hover:bg-red-500/10 hover:border-red-500/20 hover:text-red-400 py-2.5 px-4 text-xs font-semibold text-slate-400 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isResetting ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Reset Coordinates
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-xl border border-white/5 bg-white/[0.02] hover:bg-white/[0.05] py-2.5 px-4.5 text-xs font-semibold text-slate-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || isResetting}
|
||||||
|
className="flex items-center gap-1.5 rounded-xl bg-primary hover:bg-primary-hover py-2.5 px-5 text-xs font-semibold text-white shadow-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||||
|
Save Location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ interface ExternalAccountModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
user?: ManagedUser | null;
|
user?: ManagedUser | null;
|
||||||
|
targetSiteUuid?: string | null;
|
||||||
|
targetCreatedBy?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExternalAccountModal({
|
export default function ExternalAccountModal({
|
||||||
@@ -18,6 +20,8 @@ export default function ExternalAccountModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
user,
|
user,
|
||||||
|
targetSiteUuid,
|
||||||
|
targetCreatedBy,
|
||||||
}: ExternalAccountModalProps) {
|
}: ExternalAccountModalProps) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -84,6 +88,8 @@ export default function ExternalAccountModal({
|
|||||||
setSuccess(password ? "Password successfully reset & Account updated!" : "Account successfully updated!");
|
setSuccess(password ? "Password successfully reset & Account updated!" : "Account successfully updated!");
|
||||||
} else {
|
} else {
|
||||||
fd.append("role", role);
|
fd.append("role", role);
|
||||||
|
if (targetSiteUuid) fd.append("site_uuid", targetSiteUuid);
|
||||||
|
if (targetCreatedBy) fd.append("created_by", targetCreatedBy);
|
||||||
await createAdminExternalUser(fd);
|
await createAdminExternalUser(fd);
|
||||||
setSuccess("External account successfully created!");
|
setSuccess("External account successfully created!");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(null);
|
||||||
|
const [loadingFlows, setLoadingFlows] = useState(true);
|
||||||
|
const [flows, setFlows] = useState<FlowData[]>([]);
|
||||||
|
const [mapError, setMapError] = useState(false);
|
||||||
|
|
||||||
|
const mapInstanceRef = useRef<any>(null);
|
||||||
|
const layerGroupRef = useRef<any>(null);
|
||||||
|
const tileLayerRef = useRef<any>(null);
|
||||||
|
const canvasRendererRef = useRef<any>(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<string, { lat: number; lng: number; label: string; agentLabel: string; status: string }> = {};
|
||||||
|
|
||||||
|
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(
|
||||||
|
`<div class="agent-map-label ${labelClass}">${info.agentLabel}</div>`,
|
||||||
|
{ permanent: true, direction: "bottom", offset: [0, 8], className: "agent-label-tooltip" }
|
||||||
|
).openTooltip();
|
||||||
|
|
||||||
|
// Detail popup on click
|
||||||
|
const popupHtml = getAgentTooltipContent(
|
||||||
|
uuid,
|
||||||
|
info.agentLabel,
|
||||||
|
info.status,
|
||||||
|
isOnline,
|
||||||
|
info.label,
|
||||||
|
info.lat,
|
||||||
|
info.lng
|
||||||
|
);
|
||||||
|
|
||||||
|
dot.bindPopup(popupHtml, {
|
||||||
|
closeButton: false,
|
||||||
|
className: "leaflet-custom-popup",
|
||||||
|
offset: [0, -8],
|
||||||
|
});
|
||||||
|
|
||||||
|
dot.on("popupopen", () => {
|
||||||
|
const btn = document.getElementById(`btn-edit-${uuid}`);
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener("click", () => onEditLocation(uuid, info.agentLabel));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [agents, locations, flows, isDark]);
|
||||||
|
|
||||||
|
if (mapError) {
|
||||||
|
return (
|
||||||
|
<div className={`flex h-[320px] w-full flex-col items-center justify-center rounded-2xl border p-6 text-center text-red-400 ${
|
||||||
|
isDark ? "bg-slate-900/50 border-white/5" : "bg-white/50 border-slate-200"
|
||||||
|
}`}>
|
||||||
|
<Info className="mb-2 h-8 w-8" />
|
||||||
|
<p className="text-sm font-semibold">Map failed to initialize.</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Please reload the page.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative w-full rounded-2xl border p-4 shadow-md transition-all duration-300 ${
|
||||||
|
isDark
|
||||||
|
? "bg-slate-900/40 border-white/5 shadow-black/40 backdrop-blur-xl"
|
||||||
|
: "bg-white border-slate-200 shadow-slate-100"
|
||||||
|
}`}>
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: MAP_CSS_INJECT }} />
|
||||||
|
|
||||||
|
{/* Header Info */}
|
||||||
|
<div className="mb-3.5 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className={`h-4 w-4 ${isDark ? "text-primary" : "text-blue-600"}`} />
|
||||||
|
<div>
|
||||||
|
<h3 className={`text-xs font-bold uppercase tracking-wider ${
|
||||||
|
isDark ? "text-white" : "text-slate-800"
|
||||||
|
}`}>
|
||||||
|
Agent Location & Topology Map
|
||||||
|
</h3>
|
||||||
|
<p className={`text-[10px] ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||||
|
Physical deployment and real-time inter-agent routing flows across Indonesia.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingFlows && (
|
||||||
|
<div className={`flex items-center gap-1.5 text-[10px] ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin text-primary" />
|
||||||
|
<span>Updating flows...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map Element */}
|
||||||
|
<div className={`relative overflow-hidden rounded-xl border ${
|
||||||
|
isDark ? "border-white/5" : "border-slate-200"
|
||||||
|
}`}>
|
||||||
|
<div
|
||||||
|
ref={mapContainerRef}
|
||||||
|
className="h-[320px] w-full"
|
||||||
|
id="indonesia-agent-map"
|
||||||
|
style={{ background: isDark ? "#090d16" : "#e8edf2" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agents Without Coordinates Panel */}
|
||||||
|
{unregisteredAgents.length > 0 && (
|
||||||
|
<div className={`mt-3 rounded-xl border transition-colors duration-200 ${
|
||||||
|
isDark
|
||||||
|
? "border-amber-500/20 bg-amber-500/5"
|
||||||
|
: "border-blue-200 bg-blue-50"
|
||||||
|
}`}>
|
||||||
|
{/* Panel Header */}
|
||||||
|
<button
|
||||||
|
onClick={() => setPanelOpen(v => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2.5 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${isDark ? "text-amber-500" : "text-blue-500"}`} />
|
||||||
|
<span className={`text-xs font-semibold ${
|
||||||
|
isDark ? "text-amber-400" : "text-blue-700"
|
||||||
|
}`}>
|
||||||
|
{unregisteredAgents.length} Agent{unregisteredAgents.length > 1 ? "s" : ""} Without Coordinates{role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN' ? " — Click to Configure" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{panelOpen
|
||||||
|
? <ChevronUp className={`h-3.5 w-3.5 ${isDark ? "text-amber-500" : "text-blue-400"}`} />
|
||||||
|
: <ChevronDown className={`h-3.5 w-3.5 ${isDark ? "text-amber-500" : "text-blue-400"}`} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Agent Chips */}
|
||||||
|
{panelOpen && (
|
||||||
|
<div className="flex flex-wrap gap-2 px-4 pb-3 pt-0.5">
|
||||||
|
{unregisteredAgents.map(agent => {
|
||||||
|
const uuid = agent.uuid || agent.serial || "";
|
||||||
|
const isOnline = agent.activated;
|
||||||
|
const isWritable = role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={uuid}
|
||||||
|
onClick={isWritable ? () => onEditLocation(uuid, agent.label || uuid) : undefined}
|
||||||
|
disabled={!isWritable}
|
||||||
|
className={`group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
||||||
|
isDark
|
||||||
|
? isWritable
|
||||||
|
? "border-white/10 bg-slate-900/60 text-slate-300 hover:border-primary/40 hover:bg-primary/10 hover:text-primary cursor-pointer"
|
||||||
|
: "border-white/5 bg-slate-900/40 text-slate-500 cursor-default"
|
||||||
|
: isWritable
|
||||||
|
? "border-blue-200 bg-white text-slate-700 hover:border-blue-400 hover:bg-blue-50 hover:text-blue-700 cursor-pointer"
|
||||||
|
: "border-slate-200 bg-slate-50 text-slate-400 cursor-default"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MapPin className={`h-3.5 w-3.5 shrink-0 ${
|
||||||
|
isOnline ? "text-emerald-500 animate-pulse" : "text-red-400"
|
||||||
|
}`} />
|
||||||
|
<span className="max-w-[160px] truncate" title={agent.label}>{agent.label}</span>
|
||||||
|
<span className={`font-mono text-[10px] truncate max-w-[80px] ${
|
||||||
|
isDark ? "text-slate-500" : "text-slate-400"
|
||||||
|
}`} title={uuid}>{uuid}</span>
|
||||||
|
{isWritable && (
|
||||||
|
<Plus className={`h-3 w-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity ${
|
||||||
|
isDark ? "" : "text-blue-500"
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
export interface FlowDetail {
|
||||||
|
src_ip: string;
|
||||||
|
dst_ip: string;
|
||||||
|
app: string;
|
||||||
|
bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number) {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFlowTooltipContent(
|
||||||
|
srcLabel: string,
|
||||||
|
dstLabel: string,
|
||||||
|
totalBytes: number,
|
||||||
|
flowsCount: number,
|
||||||
|
details: FlowDetail[]
|
||||||
|
): string {
|
||||||
|
const mb = (totalBytes / 1024 / 1024).toFixed(2);
|
||||||
|
let html = `
|
||||||
|
<div style="padding: 12px 16px; font-family: inherit; font-size: 13px; font-weight: 500; color: #fff; background: rgba(15, 23, 42, 0.95); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); min-width: 280px; pointer-events: none;">
|
||||||
|
<div style="font-weight: 600; font-size: 14px; margin-bottom: 6px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(255,255,255,0.15); padding-bottom: 6px; color: #38bdf8;">
|
||||||
|
<span>Inter-Agent Link</span>
|
||||||
|
<span style="color: #facc15; font-family: monospace;">${mb} MB</span>
|
||||||
|
</div>
|
||||||
|
<div style="color: #cbd5e1; font-size: 11px; margin-bottom: 10px; font-weight: normal;">
|
||||||
|
${srcLabel} ➔ ${dstLabel} (${flowsCount} connections)
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 6px;">
|
||||||
|
<p style="font-size: 9px; text-transform: uppercase; font-weight: 700; color: #94a3b8; margin-bottom: 6px; margin-top: 0; letter-spacing: 0.05em;">Top Active Flows:</p>
|
||||||
|
<table style="width: 100%; text-align: left; font-family: monospace; font-size: 11px; color: #cbd5e1; border-collapse: collapse;">
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (details && details.length > 0) {
|
||||||
|
details.forEach(d => {
|
||||||
|
html += `
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 6px 3px 0; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #f1f5f9;" title="${d.src_ip}">${d.src_ip}</td>
|
||||||
|
<td style="padding: 3px 6px; color: #64748b;">➔</td>
|
||||||
|
<td style="padding: 3px 6px; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #f1f5f9;" title="${d.dst_ip}">${d.dst_ip}</td>
|
||||||
|
<td style="padding: 3px 6px; color: #38bdf8;">${d.app}</td>
|
||||||
|
<td style="padding: 3px 0 3px 6px; text-align: right; color: #4ade80; font-weight: 600;">${formatBytes(d.bytes)}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
html += `<tr><td colspan="5" style="color: #64748b; font-style: italic; text-align: center; padding: 6px 0;">No detailed flow data</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentTooltipContent(
|
||||||
|
uuid: string,
|
||||||
|
agentLabel: string,
|
||||||
|
status: string,
|
||||||
|
isOnline: boolean,
|
||||||
|
customLabel: string,
|
||||||
|
lat: number,
|
||||||
|
lng: number
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
<div style="padding: 12px 16px; font-family: inherit; font-size: 13px; font-weight: 500; color: #fff; background: rgba(15, 23, 42, 0.95); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); min-width: 200px;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; border-bottom: 1px solid rgba(255,255,255,0.15); padding-bottom: 6px;">
|
||||||
|
<span style="font-weight: 600; font-size: 14px; color: #f8fafc;">${agentLabel}</span>
|
||||||
|
<span style="padding: 2px 6px; border-radius: 4px; font-size: 9px; font-weight: bold; ${
|
||||||
|
isOnline ? "background: rgba(34,197,94,0.2); color: #4ade80;" : "background: rgba(239,68,68,0.2); color: #f87171;"
|
||||||
|
}">${status}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 11px; color: #cbd5e1; line-height: 1.5; font-weight: normal;">
|
||||||
|
<p style="margin: 3px 0;"><span style="color: #64748b; font-weight: 500;">UUID:</span> ${uuid}</p>
|
||||||
|
<p style="margin: 3px 0;"><span style="color: #64748b; font-weight: 500;">Location:</span> ${customLabel || "No custom label set"}</p>
|
||||||
|
<p style="margin: 3px 0;"><span style="color: #64748b; font-weight: 500;">Coord:</span> ${lat.toFixed(5)}, ${lng.toFixed(5)}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="btn-edit-${uuid}"
|
||||||
|
style="margin-top: 10px; width: 100%; background-color: #3b82f6; color: #fff; border: none; font-size: 11px; font-weight: 600; padding: 6px 10px; border-radius: 6px; cursor: pointer; text-align: center; transition: background-color 0.2s;"
|
||||||
|
onmouseover="this.style.backgroundColor='#2563eb'"
|
||||||
|
onmouseout="this.style.backgroundColor='#3b82f6'"
|
||||||
|
>
|
||||||
|
Configure Geolocation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAP_CSS_INJECT = `
|
||||||
|
@keyframes flowDash {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: -24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animated-flow-line {
|
||||||
|
animation: flowDash 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
border-radius: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.3s, border 0.3s;
|
||||||
|
}
|
||||||
|
.leaflet-custom-popup .leaflet-popup-content-wrapper {
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
.leaflet-custom-popup .leaflet-popup-tip {
|
||||||
|
background: #0f172a !important;
|
||||||
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
/* Light mode popup tip — matches white popup background */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.leaflet-custom-popup .leaflet-popup-tip {
|
||||||
|
background: #ffffff !important;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.leaflet-custom-popup .leaflet-popup-content {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
/* Agent label tooltips (permanent, below each circleMarker) */
|
||||||
|
.agent-label-tooltip {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
.agent-label-tooltip::before {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.agent-map-label {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: solid;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
/* Zero-out default leaflet div icon styling (kept for compatibility) */
|
||||||
|
.custom-div-icon {
|
||||||
|
background: none !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -41,10 +41,7 @@ export function TopWidgets({ topApps, topProtocols }: TopWidgetsProps) {
|
|||||||
fontSize={11}
|
fontSize={11}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => fmtBytes(value)}
|
||||||
const mb = value / 1048576;
|
|
||||||
return `${mb.toFixed(0)}M`;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<RechartsTooltip
|
<RechartsTooltip
|
||||||
cursor={{fill: 'rgba(255,255,255,0.05)'}}
|
cursor={{fill: 'rgba(255,255,255,0.05)'}}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { X, BookOpen, ChevronUp, ChevronDown, Info, HelpCircle, ExternalLink } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PAGE_GUIDES, type PageGuide } from "@/lib/helpContent";
|
||||||
|
|
||||||
|
interface ContextualHelpModalProps {
|
||||||
|
pageId: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionBlock({ heading, content, columns, items }: PageGuide["sections"][0]) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/8 bg-white/[0.02] overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-3 hover:bg-white/4 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-semibold text-white/90">{heading}</span>
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/5">
|
||||||
|
{open ? (
|
||||||
|
<ChevronUp className="h-3 w-3 text-slate-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3 w-3 text-slate-400" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="px-4 pb-4 pt-1 space-y-3 border-t border-white/5">
|
||||||
|
{content && (
|
||||||
|
<p className="text-xs text-slate-400 leading-relaxed">{content}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{columns && columns.length > 0 && (
|
||||||
|
<div className="rounded-lg overflow-hidden border border-white/5">
|
||||||
|
<div className="grid grid-cols-[140px_1fr] bg-white/[0.04] px-3 py-2 border-b border-white/5">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-blue-400">Kolom</span>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Cara Membaca</span>
|
||||||
|
</div>
|
||||||
|
{columns.map((col, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`grid grid-cols-[140px_1fr] px-3 py-2 gap-3 border-b border-white/5 last:border-0 ${
|
||||||
|
i % 2 === 0 ? "bg-white/[0.01]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono text-xs font-semibold text-blue-400 truncate"
|
||||||
|
title={col.column}
|
||||||
|
>
|
||||||
|
{col.column}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-300 leading-relaxed">
|
||||||
|
{col.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items && items.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex gap-3 rounded-lg bg-white/[0.02] border border-white/5 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-semibold text-blue-400 w-32 shrink-0 pt-0.5">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-300 leading-relaxed">
|
||||||
|
{item.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextualHelpModal({ pageId, isOpen, onClose }: ContextualHelpModalProps) {
|
||||||
|
const guide = PAGE_GUIDES.find((p) => p.id === pageId);
|
||||||
|
|
||||||
|
// Esc key closes modal
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
if (isOpen) {
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
document.body.style.overflow = "unset";
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!guide) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop blur overlay */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal content box */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 15 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 15 }}
|
||||||
|
transition={{ type: "spring", duration: 0.4 }}
|
||||||
|
className="relative w-full max-w-2xl max-h-[85vh] flex flex-col rounded-2xl border border-white/10 bg-slate-900/90 text-white shadow-2xl overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between border-b border-white/6 px-6 py-4 bg-gradient-to-r from-blue-900/20 to-indigo-900/10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-600/15 border border-blue-500/20 text-xl shadow-inner">
|
||||||
|
{guide.icon}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-base font-bold text-white">Panduan: {guide.title}</h3>
|
||||||
|
<code className="text-[10px] px-1.5 py-0.5 rounded font-mono bg-white/5 text-slate-400 border border-white/5">
|
||||||
|
{guide.url}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Penjelasan tata letak data dan kolom metrik</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg p-1.5 text-slate-400 hover:bg-white/5 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-4.5 w-4.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4">
|
||||||
|
<div className="rounded-xl border border-blue-500/10 bg-blue-500/5 px-4 py-3 text-xs text-slate-300 leading-relaxed">
|
||||||
|
<span className="font-semibold text-blue-400">Tentang Halaman Ini:</span> {guide.summary}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{guide.sections.map((section, idx) => (
|
||||||
|
<SectionBlock key={idx} {...section} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-white/6 bg-white/[0.02] px-6 py-4 flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-slate-500">
|
||||||
|
Pencarian detail lengkap tersedia di User Guide utama.
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-xl border border-white/8 bg-white/5 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:bg-white/10 hover:text-white transition-all"
|
||||||
|
>
|
||||||
|
Tutup Panduan
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/help"
|
||||||
|
className="flex items-center gap-1 rounded-xl bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white shadow-md shadow-blue-600/10 hover:bg-blue-500 transition-all"
|
||||||
|
>
|
||||||
|
Buka Panduan Lengkap
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import {
|
||||||
|
ChevronRight, Home, Radio, Shuffle, Package, Monitor,
|
||||||
|
Globe2, BarChart3, Map, Shield, Bell, Lock, BookOpen,
|
||||||
|
ChevronDown, ChevronUp, Info, Lightbulb, ArrowLeft,
|
||||||
|
Activity, Plug, Globe, Search
|
||||||
|
} from "lucide-react";
|
||||||
|
import { PAGE_GUIDES, UI_CONVENTIONS, type PageGuide } from "@/lib/helpContent";
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, React.ReactNode> = {
|
||||||
|
overview: <Home className="h-4 w-4" />,
|
||||||
|
agents: <Radio className="h-4 w-4" />,
|
||||||
|
flows: <Shuffle className="h-4 w-4" />,
|
||||||
|
apps: <Package className="h-4 w-4" />,
|
||||||
|
devices: <Monitor className="h-4 w-4" />,
|
||||||
|
dns: <Globe2 className="h-4 w-4" />,
|
||||||
|
"network-intelligence":<BarChart3 className="h-4 w-4" />,
|
||||||
|
"dpi-analytics": <Activity className="h-4 w-4" />,
|
||||||
|
geography: <Map className="h-4 w-4" />,
|
||||||
|
intelligence: <Shield className="h-4 w-4" />,
|
||||||
|
events: <Bell className="h-4 w-4" />,
|
||||||
|
"security-audit": <Lock className="h-4 w-4" />,
|
||||||
|
"network-infrastructure": <Globe className="h-4 w-4" />,
|
||||||
|
lookup: <Search className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
function SectionBlock({ heading, content, columns, items }: PageGuide["sections"][0]) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/8 bg-gradient-to-b from-slate-900/80 to-slate-900/40 overflow-hidden shadow-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="flex w-full items-center justify-between px-5 py-4 hover:bg-white/4 transition-colors text-left group"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-white/90 group-hover:text-white transition-colors">{heading}</span>
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/5 group-hover:bg-white/10 transition-colors">
|
||||||
|
{open ? <ChevronUp className="h-3.5 w-3.5 text-slate-400" /> : <ChevronDown className="h-3.5 w-3.5 text-slate-400" />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="px-5 pb-5 pt-1 space-y-3 border-t border-white/5">
|
||||||
|
{content && (
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">{content}</p>
|
||||||
|
)}
|
||||||
|
{columns && (
|
||||||
|
<div className="rounded-xl overflow-hidden border border-white/8">
|
||||||
|
<div className="grid grid-cols-[180px_1fr] bg-gradient-to-r from-blue-600/20 to-indigo-600/10 px-4 py-2.5 border-b border-white/8">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-widest text-blue-400">Kolom / Field</span>
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">Cara Membaca</span>
|
||||||
|
</div>
|
||||||
|
{columns.map((col, i) => (
|
||||||
|
<div key={i} className={`grid grid-cols-[180px_1fr] px-4 py-3 gap-4 border-b border-white/5 last:border-0 ${i % 2 === 0 ? "bg-white/[0.02]" : ""} hover:bg-white/[0.04] transition-colors`}>
|
||||||
|
<span className="font-mono text-xs font-semibold text-blue-400 self-start pt-0.5 truncate" title={col.column}>{col.column}</span>
|
||||||
|
<span className="text-xs text-slate-300 leading-relaxed">{col.description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} className="flex gap-3 rounded-xl bg-white/[0.03] border border-white/6 px-4 py-3 hover:bg-white/[0.05] transition-colors">
|
||||||
|
<span className="text-xs font-semibold text-blue-400 w-44 shrink-0 pt-0.5">{item.label}</span>
|
||||||
|
<span className="text-xs text-slate-300 leading-relaxed">{item.description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HelpPage() {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const isDark = resolvedTheme !== "light";
|
||||||
|
const [activeId, setActiveId] = useState("overview");
|
||||||
|
const activePage = PAGE_GUIDES.find(p => p.id === activeId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#080b14] text-white">
|
||||||
|
{/* ── Hero Header ── */}
|
||||||
|
<div className="relative border-b border-white/6 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-blue-600/10 via-indigo-600/5 to-transparent pointer-events-none" />
|
||||||
|
<div className="absolute top-0 left-1/3 w-96 h-32 bg-blue-600/8 blur-3xl pointer-events-none" />
|
||||||
|
<div className="max-w-6xl mx-auto px-8 py-5 flex items-center justify-between relative">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 shadow-lg shadow-blue-500/25">
|
||||||
|
<BookOpen className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-base font-bold text-white">User Guide</h1>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Panduan membaca data dashboard Deep Package Inspection</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/" className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-xs font-medium text-slate-300 hover:bg-white/10 hover:text-white transition-all hover:border-white/20 group">
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5 group-hover:-translate-x-0.5 transition-transform" />
|
||||||
|
Kembali ke Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-6xl mx-auto flex gap-6 px-8 py-6">
|
||||||
|
{/* ── Sidebar ── */}
|
||||||
|
<aside className="w-52 shrink-0">
|
||||||
|
<div className="sticky top-6 space-y-1">
|
||||||
|
<p className="px-3 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-500">Halaman Dashboard</p>
|
||||||
|
{PAGE_GUIDES.map(page => {
|
||||||
|
const isActive = activeId === page.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={page.id}
|
||||||
|
onClick={() => setActiveId(page.id)}
|
||||||
|
className={`relative flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-xs font-medium transition-all ${
|
||||||
|
isActive
|
||||||
|
? "bg-gradient-to-r from-blue-600/30 to-indigo-600/20 text-white border border-blue-500/30 shadow-md shadow-blue-500/10"
|
||||||
|
: "text-slate-400 hover:bg-white/5 hover:text-slate-200 border border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive && <span className="absolute left-0 top-1/4 h-1/2 w-0.5 rounded-full bg-blue-500" />}
|
||||||
|
<span className={`shrink-0 ${isActive ? "text-blue-400" : "text-slate-500"}`}>{ICON_MAP[page.id] ?? <Info className="h-4 w-4" />}</span>
|
||||||
|
<span className="truncate">{page.title}</span>
|
||||||
|
{isActive && <ChevronRight className="ml-auto h-3 w-3 text-blue-400 shrink-0" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="pt-2 border-t border-white/6 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveId("conventions")}
|
||||||
|
className={`relative flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-xs font-medium transition-all ${
|
||||||
|
activeId === "conventions"
|
||||||
|
? "bg-gradient-to-r from-blue-600/30 to-indigo-600/20 text-white border border-blue-500/30"
|
||||||
|
: "text-slate-400 hover:bg-white/5 hover:text-slate-200 border border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{activeId === "conventions" && <span className="absolute left-0 top-1/4 h-1/2 w-0.5 rounded-full bg-blue-500" />}
|
||||||
|
<Info className={`h-4 w-4 shrink-0 ${activeId === "conventions" ? "text-blue-400" : "text-slate-500"}`} />
|
||||||
|
<span>Konvensi Visual</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<main className="flex-1 min-w-0 space-y-4">
|
||||||
|
{activeId === "conventions" ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl border border-white/8 bg-gradient-to-br from-slate-900/80 to-slate-800/30 p-5">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500/30 to-indigo-500/20 border border-purple-500/20 text-lg">🎨</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">Konvensi Visual</h2>
|
||||||
|
<p className="text-xs text-slate-400">Panduan membaca indikator dan simbol di seluruh halaman dashboard</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/8 overflow-hidden shadow-lg">
|
||||||
|
<div className="grid grid-cols-[200px_1fr] bg-gradient-to-r from-purple-600/20 to-indigo-600/10 px-5 py-3 border-b border-white/8">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-widest text-purple-400">Simbol / Elemen</span>
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">Artinya</span>
|
||||||
|
</div>
|
||||||
|
{UI_CONVENTIONS.map((c, i) => (
|
||||||
|
<div key={i} className={`grid grid-cols-[200px_1fr] px-5 py-3.5 gap-4 border-b border-white/5 last:border-0 ${i % 2 === 0 ? "bg-white/[0.02]" : ""} hover:bg-white/[0.04] transition-colors`}>
|
||||||
|
<span className="font-mono text-xs font-semibold text-purple-400">{c.symbol}</span>
|
||||||
|
<span className="text-xs text-slate-300 leading-relaxed">{c.meaning}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-blue-500/20 bg-gradient-to-br from-blue-600/10 to-indigo-600/5 p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Lightbulb className="h-4 w-4 text-yellow-400" />
|
||||||
|
<span className="text-sm font-semibold text-yellow-300">Tips Membaca Data Secara Efektif</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2 text-xs text-slate-300">
|
||||||
|
{["Selalu cek Time Filter sebelum menganalisis — pastikan rentang waktu sesuai kebutuhan.","Gunakan filter tabel untuk mempersempit data secara spesifik.","Hover ke teks yang terpotong (...) untuk melihat nilai lengkap.","Bandingkan Download vs Upload — upload jauh lebih besar bisa mengindikasikan data exfiltration.","Gunakan Geo Traffic untuk mendeteksi koneksi ke negara yang tidak seharusnya.","Cek Threat Intelligence secara rutin untuk mengetahui ancaman aktif."].map((tip, i) => (
|
||||||
|
<li key={i} className="flex gap-2"><span className="text-blue-400 shrink-0 mt-0.5">•</span><span className="leading-relaxed">{tip}</span></li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : activePage ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl border border-white/8 bg-gradient-to-br from-slate-900/80 to-slate-800/30 p-5 shadow-xl">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-600/30 to-indigo-600/20 border border-blue-500/20 text-2xl shadow-inner">
|
||||||
|
{activePage.icon}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
|
<h2 className="text-xl font-bold text-white">{activePage.title}</h2>
|
||||||
|
<code className="text-[11px] px-2 py-0.5 rounded-lg font-mono bg-white/6 text-slate-400 border border-white/8">
|
||||||
|
{activePage.url}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-sm text-slate-400 leading-relaxed">{activePage.summary}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{activePage.sections.map((section, i) => (
|
||||||
|
<SectionBlock key={i} {...section} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-40 items-center justify-center text-slate-500 text-sm">
|
||||||
|
Pilih halaman dari sidebar untuk melihat panduannya.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import { ContextualHelpModal } from "./ContextualHelpModal";
|
||||||
|
|
||||||
|
interface HelpTriggerProps {
|
||||||
|
pageId: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelpTrigger({ pageId, className = "" }: HelpTriggerProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className={`group flex items-center gap-1.5 rounded-lg border border-white/8 bg-white/5 px-2.5 py-1.5 text-xs font-semibold text-slate-300 hover:bg-white/10 hover:text-white hover:border-white/20 transition-all ${className}`}
|
||||||
|
title="Pelajari cara membaca data halaman ini"
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-3.5 w-3.5 text-blue-500 group-hover:text-blue-400 transition-colors" />
|
||||||
|
<span>Pelajari Halaman Ini</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ContextualHelpModal
|
||||||
|
pageId={pageId}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { ReactNode, useState, useEffect } from "react";
|
import { ReactNode, useState, useEffect } from "react";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { getViewAsStatusSync, stopViewAs } from "@/lib/admin-api";
|
import { getViewAsStatusSync, stopViewAs, getViewAsHeaders } from "@/lib/admin-api";
|
||||||
import { AlertTriangle, LogOut, Clock, ShieldAlert, Loader2 } from "lucide-react";
|
import { AlertTriangle, LogOut, Clock, ShieldAlert, Loader2 } from "lucide-react";
|
||||||
import { Modal } from "@/components/ui/Modal";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
import { HelpCenterFAB } from "@/components/ui/HelpCenterFAB";
|
import { HelpCenterFAB } from "@/components/ui/HelpCenterFAB";
|
||||||
@@ -20,7 +20,7 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
setViewAs(getViewAsStatusSync());
|
setViewAs(getViewAsStatusSync());
|
||||||
|
|
||||||
// Fetch initial user session to get token exp time
|
// Fetch initial user session to get token exp time
|
||||||
fetch('/api/auth/me')
|
fetch('/api/auth/me', { headers: getViewAsHeaders() })
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.user) setUser(data.user);
|
if (data.user) setUser(data.user);
|
||||||
@@ -43,6 +43,9 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
// Session expired, force logout immediately
|
// Session expired, force logout immediately
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
setShowTimeoutWarning(false);
|
setShowTimeoutWarning(false);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('backone_view_as');
|
||||||
|
}
|
||||||
fetch('/api/auth/logout', { method: 'POST' })
|
fetch('/api/auth/logout', { method: 'POST' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
@@ -75,6 +78,9 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('backone_view_as');
|
||||||
|
}
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import ViewAsAgentBanner from "@/components/admin/ViewAsAgentBanner";
|
import ViewAsAgentBanner from "@/components/admin/ViewAsAgentBanner";
|
||||||
|
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||||
|
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
@@ -10,7 +11,7 @@ export function Header() {
|
|||||||
const [userRole, setUserRole] = useState<string>("");
|
const [userRole, setUserRole] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/auth/me')
|
fetch('/api/auth/me', { headers: getViewAsHeaders() })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => {
|
.then(d => {
|
||||||
if (d.user?.role) setUserRole(d.user.role);
|
if (d.user?.role) setUserRole(d.user.role);
|
||||||
|
|||||||
@@ -3,24 +3,26 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChevronDown, Calendar, Activity } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { AccountSettingsModal } from "./AccountSettingsModal";
|
import { AccountSettingsModal } from "./AccountSettingsModal";
|
||||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||||
|
import { useTenantConfig } from "@/contexts/TenantConfigContext";
|
||||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||||
import { NAVIGATION_GROUPS, TIME_OPTIONS } from "./SidebarData";
|
import { NAVIGATION_GROUPS, TIME_OPTIONS } from "./SidebarData";
|
||||||
import SidebarProfile from "./SidebarProfile";
|
import SidebarProfile from "./SidebarProfile";
|
||||||
import SidebarNotifications from "./SidebarNotifications";
|
import SidebarNotifications from "./SidebarNotifications";
|
||||||
|
import { SidebarSiteSwitcher } from "./SidebarSiteSwitcher";
|
||||||
|
import { SidebarTimeFilter } from "./SidebarTimeFilter";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { timeRange, setTimeRange } = useTimeFilter();
|
const { timeRange, setTimeRange } = useTimeFilter();
|
||||||
|
const { tenantConfig } = useTenantConfig();
|
||||||
|
|
||||||
const [user, setUser] = useState<{ username?: string; account_name?: string; profile_picture?: string; role?: string; agent_uuid?: string } | null>(null);
|
const [user, setUser] = useState<{ username?: string; account_name?: string; profile_picture?: string; role?: string; agent_uuid?: string } | null>(null);
|
||||||
const [isAccountOpen, setIsAccountOpen] = useState(false);
|
const [isAccountOpen, setIsAccountOpen] = useState(false);
|
||||||
const [isTimeDropdownOpen, setIsTimeDropdownOpen] = useState(false); // Time Filter dropdown
|
const [isTimeDropdownOpen, setIsTimeDropdownOpen] = useState(false);
|
||||||
const [intervalText, setIntervalText] = useState<string>("");
|
const [intervalText, setIntervalText] = useState<string>("");
|
||||||
|
|
||||||
const timeDropdownRef = useRef<HTMLDivElement>(null);
|
const timeDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -34,7 +36,17 @@ export function Sidebar() {
|
|||||||
fetch('/api/auth/me', { headers: getViewAsHeaders() })
|
fetch('/api/auth/me', { headers: getViewAsHeaders() })
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.user) setUser(data.user);
|
if (data.user) {
|
||||||
|
setUser(data.user);
|
||||||
|
if (data.user.site_uuid && data.user.role !== 'SUPER_ADMIN') {
|
||||||
|
const currentStored = localStorage.getItem('backone_site_uuid');
|
||||||
|
if (currentStored !== data.user.site_uuid) {
|
||||||
|
localStorage.setItem('backone_site_uuid', data.user.site_uuid);
|
||||||
|
setSelectedSite(data.user.site_uuid);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
|
|
||||||
@@ -87,13 +99,14 @@ export function Sidebar() {
|
|||||||
<div className="flex h-full max-h-screen flex-col border-r border-white/5 bg-black/20 backdrop-blur-3xl transition-all w-64 shrink-0 relative z-40 shadow-[4px_0_24px_-12px_rgba(0,0,0,0.5)]">
|
<div className="flex h-full max-h-screen flex-col border-r border-white/5 bg-black/20 backdrop-blur-3xl transition-all w-64 shrink-0 relative z-40 shadow-[4px_0_24px_-12px_rgba(0,0,0,0.5)]">
|
||||||
{/* Brand & Site Info */}
|
{/* Brand & Site Info */}
|
||||||
<div className="flex flex-col p-2 shrink-0 gap-4 border-b border-white/5">
|
<div className="flex flex-col p-2 shrink-0 gap-4 border-b border-white/5">
|
||||||
<div className="flex items-center justify-center gap-3 w-full pt-4 pb-2 px-1">
|
<div className="flex flex-col items-center justify-center text-center w-full pt-4 pb-2 px-2 gap-2">
|
||||||
<img src="/backone-logo.png" alt="BackOne Logo" className="w-9 h-9 rounded-full drop-shadow-[0_0_12px_rgba(255,0,0,0.35)] shrink-0" />
|
<img
|
||||||
<div className="flex flex-col min-w-0 justify-center text-left">
|
src={tenantConfig?.brandLogo || "/backone-logo.png"}
|
||||||
<span className="text-xl font-bold font-backone text-foreground tracking-tight leading-none">
|
alt="Brand Logo"
|
||||||
BackOne
|
className="h-10 object-contain drop-shadow-[0_0_12px_rgba(255,0,0,0.2)] shrink-0"
|
||||||
</span>
|
/>
|
||||||
<span className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold mt-1">
|
<div className="flex flex-col items-center justify-center text-center">
|
||||||
|
<span className="text-[9px] uppercase tracking-[0.1em] text-slate-400 font-semibold mt-1">
|
||||||
Deep Package Inspection
|
Deep Package Inspection
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5 mt-2 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-[8px] font-bold px-2 py-0.5 rounded-full w-fit">
|
<div className="flex items-center gap-1.5 mt-2 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-[8px] font-bold px-2 py-0.5 rounded-full w-fit">
|
||||||
@@ -103,108 +116,30 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Global Site Switcher Widget (Visible to SUPER_ADMIN, SOC_ANALYST, ENGINEER) */}
|
{/* Global Site Switcher Widget */}
|
||||||
{user && ['SUPER_ADMIN', 'SOC_ANALYST', 'ENGINEER'].includes(user.role || '') && (
|
<SidebarSiteSwitcher
|
||||||
<div className="relative w-full px-2" ref={siteDropdownRef}>
|
user={user}
|
||||||
<button
|
selectedSite={selectedSite}
|
||||||
onClick={() => setIsSiteDropdownOpen(!isSiteDropdownOpen)}
|
isSiteDropdownOpen={isSiteDropdownOpen}
|
||||||
className="flex w-full items-center justify-between gap-2 rounded-xl border border-white/5 bg-white/[0.02] hover:bg-white/[0.05] p-2.5 text-xs text-slate-300 transition-colors"
|
setIsSiteDropdownOpen={setIsSiteDropdownOpen}
|
||||||
>
|
siteDropdownRef={siteDropdownRef}
|
||||||
<div className="flex items-center gap-2 truncate">
|
handleSiteSelect={handleSiteSelect}
|
||||||
<Activity className="h-3.5 w-3.5 text-primary shrink-0" />
|
getSiteLabel={getSiteLabel}
|
||||||
<div className="flex flex-col items-start truncate leading-normal">
|
/>
|
||||||
<span className="font-semibold text-[10px] text-primary uppercase tracking-wider">Active Site</span>
|
|
||||||
<span className="truncate max-w-[130px]">{getSiteLabel(selectedSite)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform duration-300", isSiteDropdownOpen && "rotate-180")} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute top-full left-2 right-2 mt-1 rounded-xl border border-white/10 bg-slate-950 p-1.5 shadow-2xl z-50 origin-top transition-all duration-300 ease-out",
|
|
||||||
isSiteDropdownOpen
|
|
||||||
? "opacity-100 scale-100 pointer-events-auto translate-y-0"
|
|
||||||
: "opacity-0 scale-95 pointer-events-none -translate-y-2"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<button
|
|
||||||
onClick={() => handleSiteSelect('6681452d_9cae_4ff4_8ae8_0d504774265e')}
|
|
||||||
className={cn(
|
|
||||||
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors",
|
|
||||||
selectedSite === '6681452d_9cae_4ff4_8ae8_0d504774265e'
|
|
||||||
? "bg-primary/20 text-white font-medium"
|
|
||||||
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
SIAB
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSiteSelect('d7902405_0dc2_458b_8584_ed4d24b64f24')}
|
|
||||||
className={cn(
|
|
||||||
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors",
|
|
||||||
selectedSite === 'd7902405_0dc2_458b_8584_ed4d24b64f24'
|
|
||||||
? "bg-primary/20 text-white font-medium"
|
|
||||||
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Nexus
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Global Time Filter Widget */}
|
{/* Global Time Filter Widget */}
|
||||||
<div className="relative w-full px-2 pb-2" ref={timeDropdownRef}>
|
<SidebarTimeFilter
|
||||||
<button
|
timeRange={timeRange}
|
||||||
onClick={() => setIsTimeDropdownOpen(!isTimeDropdownOpen)}
|
setTimeRange={setTimeRange}
|
||||||
className="flex w-full items-center justify-between gap-2 rounded-xl border border-white/5 bg-white/[0.02] hover:bg-white/[0.05] p-2.5 text-xs text-slate-300 transition-colors"
|
isTimeDropdownOpen={isTimeDropdownOpen}
|
||||||
>
|
setIsTimeDropdownOpen={setIsTimeDropdownOpen}
|
||||||
<div className="flex items-center gap-2 truncate">
|
timeDropdownRef={timeDropdownRef}
|
||||||
<Calendar className="h-3.5 w-3.5 text-primary shrink-0" />
|
selectedTimeOption={selectedTimeOption}
|
||||||
<div className="flex flex-col items-start truncate leading-normal">
|
timeOptions={TIME_OPTIONS}
|
||||||
<span className="font-semibold text-[10px] text-primary uppercase tracking-wider">Time Filter</span>
|
/>
|
||||||
<span className="truncate max-w-[130px]">{selectedTimeOption.label}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform duration-300", isTimeDropdownOpen && "rotate-180")} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute top-full left-2 right-2 mt-1 rounded-xl border border-white/10 bg-slate-950 p-1.5 shadow-2xl z-50 origin-top transition-all duration-300 ease-out",
|
|
||||||
isTimeDropdownOpen
|
|
||||||
? "opacity-100 scale-100 pointer-events-auto translate-y-0"
|
|
||||||
: "opacity-0 scale-95 pointer-events-none -translate-y-2"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-0.5 max-h-60 overflow-y-auto custom-scrollbar">
|
|
||||||
{TIME_OPTIONS.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
onClick={() => {
|
|
||||||
setTimeRange(opt.value);
|
|
||||||
setIsTimeDropdownOpen(false);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors",
|
|
||||||
timeRange === opt.value
|
|
||||||
? "bg-primary/20 text-white font-medium"
|
|
||||||
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation (Flexible Scrollable Container for Rule 9) */}
|
{/* Navigation */}
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-3 custom-scrollbar h-full">
|
<div className="flex-1 overflow-y-auto px-4 py-3 custom-scrollbar h-full">
|
||||||
{intervalText && (
|
{intervalText && (
|
||||||
<div className="mb-4 rounded-lg bg-blue-500/5 border border-blue-500/10 p-2 text-center">
|
<div className="mb-4 rounded-lg bg-blue-500/5 border border-blue-500/10 p-2 text-center">
|
||||||
@@ -215,10 +150,9 @@ export function Sidebar() {
|
|||||||
|
|
||||||
<nav className="flex flex-col gap-5">
|
<nav className="flex flex-col gap-5">
|
||||||
{NAVIGATION_GROUPS.map((group) => {
|
{NAVIGATION_GROUPS.map((group) => {
|
||||||
// Role-based navigation item filtering
|
|
||||||
const allowedItems = group.items.filter((item) => {
|
const allowedItems = group.items.filter((item) => {
|
||||||
if (item.href === "/agents") {
|
if (item.href === "/agents") {
|
||||||
return user?.role === "SUPER_ADMIN";
|
return user?.role === "SUPER_ADMIN" || user?.role === "TENANT_ADMIN";
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const NAVIGATION_GROUPS = [
|
|||||||
{ name: "Traffic Categories", href: "/network-intelligence", icon: Activity },
|
{ name: "Traffic Categories", href: "/network-intelligence", icon: Activity },
|
||||||
{ name: "DNS", href: "/dns", icon: Radio },
|
{ name: "DNS", href: "/dns", icon: Radio },
|
||||||
{ name: "Geo Traffic", href: "/geography", icon: Globe },
|
{ name: "Geo Traffic", href: "/geography", icon: Globe },
|
||||||
{ name: "BackOne Metadata", href: "/dpi-analytics", icon: Activity },
|
{ name: "DPI MetaData", href: "/dpi-analytics", icon: Activity },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
|
|||||||
<div className="h-px bg-border/50 my-1 mx-2" />
|
<div className="h-px bg-border/50 my-1 mx-2" />
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('backone_view_as');
|
||||||
|
}
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Activity, ChevronDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface SidebarSiteSwitcherProps {
|
||||||
|
user: { role?: string; site_uuid?: string | null } | null;
|
||||||
|
selectedSite: string;
|
||||||
|
isSiteDropdownOpen: boolean;
|
||||||
|
setIsSiteDropdownOpen: (open: boolean) => void;
|
||||||
|
siteDropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
handleSiteSelect: (val: string) => void;
|
||||||
|
getSiteLabel: (uuid: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarSiteSwitcher({
|
||||||
|
user,
|
||||||
|
selectedSite,
|
||||||
|
isSiteDropdownOpen,
|
||||||
|
setIsSiteDropdownOpen,
|
||||||
|
siteDropdownRef,
|
||||||
|
handleSiteSelect,
|
||||||
|
getSiteLabel,
|
||||||
|
}: SidebarSiteSwitcherProps) {
|
||||||
|
const isGlobalUser = user?.role === 'SUPER_ADMIN' ||
|
||||||
|
((!user?.site_uuid || user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(user?.role || ''));
|
||||||
|
|
||||||
|
if (!user || !isGlobalUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full px-2" ref={siteDropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSiteDropdownOpen(!isSiteDropdownOpen)}
|
||||||
|
className="flex w-full items-center justify-between gap-2 rounded-xl border border-white/5 bg-white/[0.02] hover:bg-white/[0.05] p-2.5 text-xs text-slate-300 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 truncate">
|
||||||
|
<Activity className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||||
|
<div className="flex flex-col items-start truncate leading-normal">
|
||||||
|
<span className="font-semibold text-[10px] text-primary uppercase tracking-wider">Active Site</span>
|
||||||
|
<span className="truncate max-w-[130px]">{getSiteLabel(selectedSite)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform duration-300", isSiteDropdownOpen && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-full left-2 right-2 mt-1 rounded-xl border border-white/10 bg-slate-950 p-1.5 shadow-2xl z-50 origin-top transition-all duration-300 ease-out",
|
||||||
|
isSiteDropdownOpen
|
||||||
|
? "opacity-100 scale-100 pointer-events-auto translate-y-0"
|
||||||
|
: "opacity-0 scale-95 pointer-events-none -translate-y-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSiteSelect('6681452d_9cae_4ff4_8ae8_0d504774265e')}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors",
|
||||||
|
selectedSite === '6681452d_9cae_4ff4_8ae8_0d504774265e'
|
||||||
|
? "bg-primary/20 text-white font-medium"
|
||||||
|
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
SIAB
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSiteSelect('d7902405_0dc2_458b_8584_ed4d24b64f24')}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors",
|
||||||
|
selectedSite === 'd7902405_0dc2_458b_8584_ed4d24b64f24'
|
||||||
|
? "bg-primary/20 text-white font-medium"
|
||||||
|
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Nexus
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Calendar, ChevronDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface TimeOption {
|
||||||
|
readonly value: "5m" | "30m" | "1h" | "1d" | "7d";
|
||||||
|
readonly label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarTimeFilterProps {
|
||||||
|
timeRange: string;
|
||||||
|
setTimeRange: (range: any) => void;
|
||||||
|
isTimeDropdownOpen: boolean;
|
||||||
|
setIsTimeDropdownOpen: (open: boolean) => void;
|
||||||
|
timeDropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
selectedTimeOption: TimeOption;
|
||||||
|
timeOptions: readonly TimeOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarTimeFilter({
|
||||||
|
timeRange,
|
||||||
|
setTimeRange,
|
||||||
|
isTimeDropdownOpen,
|
||||||
|
setIsTimeDropdownOpen,
|
||||||
|
timeDropdownRef,
|
||||||
|
selectedTimeOption,
|
||||||
|
timeOptions,
|
||||||
|
}: SidebarTimeFilterProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full px-2 pb-2" ref={timeDropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsTimeDropdownOpen(!isTimeDropdownOpen)}
|
||||||
|
className="flex w-full items-center justify-between gap-2 rounded-xl border border-white/5 bg-white/[0.02] hover:bg-white/[0.05] p-2.5 text-xs text-slate-300 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 truncate">
|
||||||
|
<Calendar className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||||
|
<div className="flex flex-col items-start truncate leading-normal">
|
||||||
|
<span className="font-semibold text-[10px] text-primary uppercase tracking-wider">Time Filter</span>
|
||||||
|
<span className="truncate max-w-[130px]">{selectedTimeOption.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform duration-300", isTimeDropdownOpen && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-full left-2 right-2 mt-1 rounded-xl border border-white/10 bg-slate-950 p-1.5 shadow-2xl z-50 origin-top transition-all duration-300 ease-out",
|
||||||
|
isTimeDropdownOpen
|
||||||
|
? "opacity-100 scale-100 pointer-events-auto translate-y-0"
|
||||||
|
: "opacity-0 scale-95 pointer-events-none -translate-y-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5 max-h-60 overflow-y-auto custom-scrollbar">
|
||||||
|
{timeOptions.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => {
|
||||||
|
setTimeRange(opt.value);
|
||||||
|
setIsTimeDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors",
|
||||||
|
timeRange === opt.value
|
||||||
|
? "bg-primary/20 text-white font-medium"
|
||||||
|
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useLookupApplications, useLookupCategories, LookupApp } from "@/lib/api";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { DropdownFilterSelect } from "@/components/ui/DropdownFilterSelect";
|
||||||
|
import { Loader2, Search, AppWindow, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, X, Info, Tag, Box, Shield, Server } from "lucide-react";
|
||||||
|
|
||||||
|
export function ApplicationCatalog() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [selectedApp, setSelectedApp] = useState<LookupApp | null>(null);
|
||||||
|
const [isClosingModal, setIsClosingModal] = useState(false);
|
||||||
|
const limit = 24;
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsClosingModal(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setSelectedApp(null);
|
||||||
|
setIsClosingModal(false);
|
||||||
|
}, 480);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedSearch(searchQuery);
|
||||||
|
setPage(1);
|
||||||
|
}, 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]);
|
||||||
|
|
||||||
|
const applications = data?.applications || [];
|
||||||
|
const pagination = data?.pagination || { total_records: 0, total_pages: 1, current_page: 1 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Search & Filter Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 animate-slide-up relative z-50" style={{ animationDelay: '50ms' }}>
|
||||||
|
<div className="relative flex-1 max-w-md w-full">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by app name or tag (e.g. facebook, openvpn)..."
|
||||||
|
className="w-full bg-slate-900 border border-border/50 rounded-lg pl-10 pr-4 py-2 text-sm text-white placeholder-slate-400 focus:outline-none hover:border-slate-700 transition-all duration-200"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full sm:w-64 z-50">
|
||||||
|
<DropdownFilterSelect
|
||||||
|
value={selectedCategory || "All"}
|
||||||
|
onChange={(val) => {
|
||||||
|
setSelectedCategory(val === "All" ? "" : val);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
options={categories || []}
|
||||||
|
placeholder="All Categories"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cards Grid */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-20">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-8 bg-red-950/20 border border-red-500/30 rounded-xl text-center text-red-400">
|
||||||
|
<p className="font-semibold">Failed to load application lookup database</p>
|
||||||
|
<p className="text-sm mt-1">{(error as any).message}</p>
|
||||||
|
</div>
|
||||||
|
) : applications.length === 0 ? (
|
||||||
|
<div className="p-16 bg-card/40 border border-border/50 rounded-2xl text-center text-slate-400 max-w-lg mx-auto">
|
||||||
|
<AppWindow className="h-12 w-12 mx-auto mb-4 text-slate-500" />
|
||||||
|
<p className="font-semibold text-white">No applications found</p>
|
||||||
|
<p className="text-sm mt-1">Try refining your search keyword or clearing the input.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||||
|
{applications.map((app: LookupApp) => (
|
||||||
|
<Card
|
||||||
|
key={app.id}
|
||||||
|
onClick={() => setSelectedApp(app)}
|
||||||
|
className="group relative overflow-hidden bg-card/60 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-primary/40 transition-all duration-300 flex flex-col justify-between cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="p-5 flex flex-col h-full justify-between gap-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-secondary/80 flex items-center justify-center overflow-hidden border border-border/40 group-hover:border-primary/20 transition-all">
|
||||||
|
{app.favicon || app.logo ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={app.favicon || app.logo}
|
||||||
|
alt={app.label}
|
||||||
|
className="h-6 w-6 object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
const img = e.target as HTMLElement;
|
||||||
|
img.style.display = 'none';
|
||||||
|
const sibling = img.nextSibling as HTMLElement;
|
||||||
|
if (sibling) sibling.style.display = 'block';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AppWindow className="h-5 w-5 text-slate-400" style={{ display: 'none' }} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<AppWindow className="h-5 w-5 text-slate-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] uppercase font-semibold tracking-wider text-primary bg-primary/10 border border-primary/20 px-2 py-0.5 rounded-full">
|
||||||
|
{app.application_category?.label || "Unclassified"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white group-hover:text-primary transition-colors text-base line-clamp-1">
|
||||||
|
{app.label}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-slate-500 font-mono mt-0.5">
|
||||||
|
{app.tag?.replace(/^backone\./i, "")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-400 line-clamp-3 leading-relaxed mt-2">
|
||||||
|
{app.description || "No description provided for this application definition."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-border/30 text-[10px] text-slate-500 font-mono">
|
||||||
|
<span>ID: {app.id}</span>
|
||||||
|
<span>{app.name || "N/A"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
{pagination.total_pages > 1 && (
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 pt-4 border-t border-border/50 text-slate-400 text-sm animate-slide-up" style={{ animationDelay: '150ms' }}>
|
||||||
|
<div>
|
||||||
|
Showing <span className="text-white font-medium">{((page - 1) * limit) + 1}</span> to{" "}
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{Math.min(page * limit, pagination.total_records)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="text-white font-medium">{pagination.total_records}</span> entries
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(1)}
|
||||||
|
disabled={page === 1 || isLoading}
|
||||||
|
className="p-2 rounded-lg bg-card border border-border/50 text-slate-400 hover:bg-secondary disabled:opacity-30 disabled:hover:bg-card transition-colors"
|
||||||
|
title="First Page"
|
||||||
|
>
|
||||||
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(p - 1, 1))}
|
||||||
|
disabled={page === 1 || isLoading}
|
||||||
|
className="p-2 rounded-lg bg-card border border-border/50 text-slate-400 hover:bg-secondary disabled:opacity-30 disabled:hover:bg-card transition-colors"
|
||||||
|
title="Previous Page"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="px-4 py-1.5 rounded-lg bg-secondary/50 border border-border/30 text-white font-mono">
|
||||||
|
{page} / {pagination.total_pages}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(p + 1, pagination.total_pages))}
|
||||||
|
disabled={page === pagination.total_pages || isLoading}
|
||||||
|
className="p-2 rounded-lg bg-card border border-border/50 text-slate-400 hover:bg-secondary disabled:opacity-30 disabled:hover:bg-card transition-colors"
|
||||||
|
title="Next Page"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(pagination.total_pages)}
|
||||||
|
disabled={page === pagination.total_pages || isLoading}
|
||||||
|
className="p-2 rounded-lg bg-card border border-border/50 text-slate-400 hover:bg-secondary disabled:opacity-30 disabled:hover:bg-card transition-colors"
|
||||||
|
title="Last Page"
|
||||||
|
>
|
||||||
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Application Detail Modal */}
|
||||||
|
{selectedApp && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6" onClick={handleCloseModal}>
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosingModal ? 'animate-overlay-out' : 'animate-overlay-in'}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`relative w-full max-w-2xl bg-card border border-border rounded-2xl shadow-2xl shadow-black/60 overflow-hidden ${isClosingModal ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="h-32 bg-gradient-to-br from-primary/20 via-primary/5 to-transparent relative">
|
||||||
|
<button
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="absolute top-4 right-4 p-2 bg-black/20 hover:bg-black/40 rounded-full text-white/70 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 pb-6 sm:px-8 sm:pb-8 relative -mt-16">
|
||||||
|
<div className="w-24 h-24 bg-card rounded-2xl border-4 border-card flex items-center justify-center overflow-hidden shadow-xl relative z-10 mb-4 bg-secondary">
|
||||||
|
{selectedApp.logo || selectedApp.favicon ? (
|
||||||
|
<img
|
||||||
|
src={selectedApp.logo || selectedApp.favicon}
|
||||||
|
alt={selectedApp.label}
|
||||||
|
className="w-16 h-16 object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
const img = e.target as HTMLElement;
|
||||||
|
img.style.display = 'none';
|
||||||
|
const sibling = img.nextSibling as HTMLElement;
|
||||||
|
if (sibling) sibling.style.display = 'block';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AppWindow className="w-12 h-12 text-slate-400" style={{ display: (selectedApp.logo || selectedApp.favicon) ? 'none' : 'block' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-white tracking-tight">{selectedApp.label}</h2>
|
||||||
|
{selectedApp.application_category?.label && (
|
||||||
|
<span className="px-3 py-1 bg-primary/10 border border-primary/20 text-primary text-xs font-bold uppercase tracking-widest rounded-full">
|
||||||
|
{selectedApp.application_category.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedApp.full_name && selectedApp.full_name !== selectedApp.label && (
|
||||||
|
<p className="text-slate-400 font-medium">{selectedApp.full_name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4 md:col-span-2">
|
||||||
|
<div className="flex items-start gap-3 text-slate-300 bg-secondary/30 p-4 rounded-xl border border-border/30">
|
||||||
|
<Info className="w-5 h-5 text-primary shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm leading-relaxed">
|
||||||
|
{selectedApp.description || "No detailed description provided for this application or service."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-xs uppercase font-bold text-slate-500 tracking-wider">System Metadata</h3>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<Box className="w-4 h-4" />
|
||||||
|
<span className="text-sm">App ID</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.id}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<Tag className="w-4 h-4" />
|
||||||
|
<span className="text-sm">System Name</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.name || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
<span className="text-sm">Tag / Alias</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">
|
||||||
|
{selectedApp.tag?.replace(/^backone\./i, "") || 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedApp.application_category && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-xs uppercase font-bold text-slate-500 tracking-wider">Category Info</h3>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<Server className="w-4 h-4" />
|
||||||
|
<span className="text-sm">Category ID</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.application_category.id}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
<span className="text-sm">Category Tag</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">
|
||||||
|
{selectedApp.application_category.tag?.replace(/^backone\./i, "")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useLookupCategories } from "@/lib/api";
|
||||||
|
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||||
|
import { Card, CardContent } from "@/components/ui/Card";
|
||||||
|
import { DropdownFilterSelect } from "@/components/ui/DropdownFilterSelect";
|
||||||
|
import { Loader2, ShieldAlert, Plus, Trash2, Globe, Tag, CheckCircle, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
interface BlacklistRule {
|
||||||
|
_id: string;
|
||||||
|
type: 'category' | 'domain';
|
||||||
|
value: string;
|
||||||
|
is_active: boolean;
|
||||||
|
agent_uuid?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlacklistConfiguration() {
|
||||||
|
const [rules, setRules] = useState<BlacklistRule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
const [userRole, setUserRole] = useState<string>("");
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [type, setType] = useState<'category' | 'domain'>('domain');
|
||||||
|
const [customDomain, setCustomDomain] = useState("");
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState("");
|
||||||
|
|
||||||
|
const { data: categories, isLoading: categoriesLoading } = useLookupCategories();
|
||||||
|
|
||||||
|
const fetchRules = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch("/api/dashboard/blacklist", {
|
||||||
|
headers: getViewAsHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to load blacklist rules");
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.ok) {
|
||||||
|
setRules(json.data);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err.message || "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRules();
|
||||||
|
// Fetch active user role to enforce RBAC in the frontend
|
||||||
|
fetch('/api/auth/me', { headers: getViewAsHeaders() })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(d => {
|
||||||
|
if (d.user?.role) {
|
||||||
|
setUserRole(d.user.role);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error("Failed to fetch user context:", err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddRule = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (userRole !== 'AGENT_VIEWER') return;
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
const value = type === 'category' ? selectedCategory : customDomain.trim();
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
setError(`Please ${type === 'category' ? 'select a category' : 'input a domain'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const res = await fetch("/api/dashboard/blacklist", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...getViewAsHeaders()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type, value }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(json.error || "Failed to add blacklist rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json.ok) {
|
||||||
|
setSuccess(`Successfully added blacklist rule for ${value}`);
|
||||||
|
setCustomDomain("");
|
||||||
|
setSelectedCategory("");
|
||||||
|
fetchRules();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to save blacklist rule");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRule = async (id: string) => {
|
||||||
|
if (userRole !== 'AGENT_VIEWER') return;
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
if (!confirm("Are you sure you want to remove this blacklist rule?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/dashboard/blacklist/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: getViewAsHeaders(),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(json.error || "Failed to delete blacklist rule");
|
||||||
|
}
|
||||||
|
setSuccess("Blacklist rule removed successfully");
|
||||||
|
fetchRules();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to delete blacklist rule");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canEdit = userRole === 'AGENT_VIEWER';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Informative Banner if User cannot edit */}
|
||||||
|
{!canEdit && userRole && (
|
||||||
|
<div className="flex items-start gap-3 p-4 bg-amber-950/20 border border-amber-500/30 rounded-xl text-amber-400 text-sm leading-relaxed animate-fade-in">
|
||||||
|
<AlertCircle className="w-5 h-5 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">Management Restricted (Read-Only)</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
You are currently viewing this dashboard as an administrator/analyst. Only Network Agents (or Admins in **View As Agent** mode) are authorized to modify blacklist rules.
|
||||||
|
</p>
|
||||||
|
{(userRole === 'SUPER_ADMIN' || userRole === 'TENANT_ADMIN') && (
|
||||||
|
<p className="mt-2 text-xs text-amber-300/80">
|
||||||
|
💡 To customize this blacklist, go to the <a href="/agents" className="underline hover:text-white font-semibold">Agents Inventory</a> tab and click the **View As** button on a specific agent.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Left Column: Form to Add Blacklist */}
|
||||||
|
<div className="lg:col-span-1 space-y-4">
|
||||||
|
<Card className={`border-border/50 bg-card/60 backdrop-blur-xl transition-opacity duration-300 ${!canEdit ? 'opacity-60' : ''}`}>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||||
|
<ShieldAlert className="w-5 h-5 text-red-500" />
|
||||||
|
Add Blacklist Rule
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleAddRule} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider block mb-2">Rule Type</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2 bg-slate-950 p-1.5 rounded-lg border border-border/30">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canEdit}
|
||||||
|
onClick={() => { setType('domain'); setError(null); }}
|
||||||
|
className={`py-1.5 text-xs font-bold rounded-md transition-all disabled:opacity-50 ${
|
||||||
|
type === 'domain' ? "bg-primary text-white" : "text-slate-400 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Custom Domain
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canEdit}
|
||||||
|
onClick={() => { setType('category'); setError(null); }}
|
||||||
|
className={`py-1.5 text-xs font-bold rounded-md transition-all disabled:opacity-50 ${
|
||||||
|
type === 'category' ? "bg-primary text-white" : "text-slate-400 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Category Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type === 'domain' ? (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider block mb-2">Domain / Keyword</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
disabled={!canEdit}
|
||||||
|
placeholder={canEdit ? "E.g., tiktok.com or torrent" : "Restricted - View As Agent to edit"}
|
||||||
|
className="w-full bg-slate-900 border border-border/50 rounded-lg px-3 py-2 text-sm text-white focus:outline-none hover:border-slate-700 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
value={customDomain}
|
||||||
|
onChange={(e) => setCustomDomain(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider block mb-2">Select Category</label>
|
||||||
|
{categoriesLoading ? (
|
||||||
|
<div className="flex justify-center p-2"><Loader2 className="w-4 h-4 animate-spin text-slate-500" /></div>
|
||||||
|
) : (
|
||||||
|
<DropdownFilterSelect
|
||||||
|
value={selectedCategory || "Select Category"}
|
||||||
|
onChange={canEdit ? setSelectedCategory : () => {}}
|
||||||
|
options={categories || []}
|
||||||
|
placeholder="Choose category..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-950/20 border border-red-500/30 rounded-lg text-xs text-red-400">
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-emerald-950/20 border border-emerald-500/30 rounded-lg text-xs text-emerald-400">
|
||||||
|
<CheckCircle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{success}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEdit ? (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full py-2.5 bg-primary hover:bg-primary-hover disabled:bg-primary/50 text-white font-semibold text-sm rounded-lg flex items-center justify-center gap-2 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Apply Rule</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-2 text-xs text-slate-500 bg-secondary/15 rounded-lg border border-border/10 font-medium">
|
||||||
|
Read-only mode
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Rules List */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<Card className="border-border/50 bg-card/60 backdrop-blur-xl">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-4">Active Blacklist Rules</h3>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center p-16">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : rules.length === 0 ? (
|
||||||
|
<div className="text-center p-16 text-slate-500 bg-secondary/10 border border-border/30 rounded-xl">
|
||||||
|
<ShieldAlert className="w-10 h-10 mx-auto mb-2 text-slate-600" />
|
||||||
|
<p className="font-semibold text-white/90">No blacklist rules configured</p>
|
||||||
|
<p className="text-xs mt-1">Incoming flows matching blacklisted applications or categories will not trigger threats.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse text-left text-sm text-slate-300">
|
||||||
|
<thead className="bg-secondary/40 text-slate-400 font-mono text-[10px] uppercase tracking-wider border-b border-border/30">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3 w-[15%] text-center">Type</th>
|
||||||
|
<th className="p-3 w-[45%] text-center">Value</th>
|
||||||
|
<th className="p-3 w-[25%] text-center">Agent</th>
|
||||||
|
{canEdit && <th className="p-3 w-[15%] text-center">Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/20">
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<tr key={rule._id} className="hover:bg-secondary/10 transition-colors">
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<span className={`inline-flex items-center gap-1 text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 rounded ${
|
||||||
|
rule.type === 'category'
|
||||||
|
? "bg-purple-950/40 text-purple-400 border border-purple-500/20"
|
||||||
|
: "bg-blue-950/40 text-blue-400 border border-blue-500/20"
|
||||||
|
}`}>
|
||||||
|
{rule.type === 'category' ? <Tag className="w-3 h-3" /> : <Globe className="w-3 h-3" />}
|
||||||
|
{rule.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center font-mono text-white text-xs">{rule.value}</td>
|
||||||
|
<td className="p-3 text-center font-mono text-slate-400 text-[11px] truncate max-w-[120px]" title={rule.agent_uuid}>
|
||||||
|
{rule.agent_uuid || "-"}
|
||||||
|
</td>
|
||||||
|
{canEdit && (
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteRule(rule._id)}
|
||||||
|
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-950/30 rounded transition-colors"
|
||||||
|
title="Delete Rule"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mx-auto" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -89,12 +89,7 @@ export default function TlsVersionsChart({ versionsData }: Props) {
|
|||||||
scale="log"
|
scale="log"
|
||||||
domain={[1, 'auto']}
|
domain={[1, 'auto']}
|
||||||
allowDataOverflow
|
allowDataOverflow
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => fmtBytes(value)}
|
||||||
if (value >= 1073741824) return `${(value / 1073741824).toFixed(0)}GB`;
|
|
||||||
if (value >= 1048576) return `${(value / 1048576).toFixed(0)}MB`;
|
|
||||||
if (value >= 1024) return `${(value / 1024).toFixed(0)}KB`;
|
|
||||||
return `${value}B`;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<RechartsTooltip content={<TlsVersionTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
|
<RechartsTooltip content={<TlsVersionTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
|
||||||
<Bar dataKey="total" radius={[4, 4, 0, 0]} minPointSize={6}>
|
<Bar dataKey="total" radius={[4, 4, 0, 0]} minPointSize={6}>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function DateRangePicker({
|
|||||||
{open && (
|
{open && (
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 bg-slate-900 border border-border/50 rounded-xl shadow-2xl z-50 p-4 min-w-[260px]">
|
<div className="absolute top-full left-0 right-0 mt-1 bg-slate-900 border border-border/50 rounded-xl shadow-2xl z-50 p-4 min-w-[260px]">
|
||||||
<p className="text-xs text-muted-foreground mb-3 font-medium">
|
<p className="text-xs text-muted-foreground mb-3 font-medium">
|
||||||
Filter Threats by Date
|
Filter by Date Range
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props)
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="text-xl font-bold text-card-foreground tracking-tight">
|
<h3 className="text-xl font-bold text-card-foreground tracking-tight">
|
||||||
{ip || data?.ip_address || 'Resolving IP...'}
|
{ip || data?.ip_address || (isLoading ? 'Resolving IP...' : 'No IP Address')}
|
||||||
</h3>
|
</h3>
|
||||||
{isRefreshing && <RefreshCcw className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 animate-spin" />}
|
{isRefreshing && <RefreshCcw className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 animate-spin" />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import Link from "next/link";
|
||||||
import { HelpCircle, X, Search, PlayCircle, ChevronDown, ChevronUp, FileText, BookOpen } from 'lucide-react';
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { useTheme } from 'next-themes';
|
import {
|
||||||
|
HelpCircle,
|
||||||
|
X,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
BookOpen,
|
||||||
|
ArrowRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useTenantConfig } from "@/contexts/TenantConfigContext";
|
||||||
|
import { FAQS } from "@/lib/helpContent";
|
||||||
|
|
||||||
const FAQItem = ({ question, answer }: { question: string, answer: string }) => {
|
// ─── Expandable FAQ Item ───────────────────────────────────────────────────────
|
||||||
|
const FAQItem = ({ question, answer }: { question: string; answer: string }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border/50 py-3">
|
<div className="border-b border-border/50 py-3 last:border-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="flex w-full items-center justify-between text-left focus:outline-none"
|
className="flex w-full items-center justify-between text-left focus:outline-none gap-3"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium text-foreground">{question}</span>
|
<span className="text-sm font-medium text-foreground leading-snug">{question}</span>
|
||||||
{isOpen ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
{isOpen ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ height: 0, opacity: 0 }}
|
initial={{ height: 0, opacity: 0 }}
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
exit={{ height: 0, opacity: 0 }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<p className="pt-2 text-sm text-muted-foreground leading-relaxed">
|
<p className="pt-2 text-sm text-muted-foreground leading-relaxed">{answer}</p>
|
||||||
{answer}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -35,28 +48,10 @@ const FAQItem = ({ question, answer }: { question: string, answer: string }) =>
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Main FAB Component ────────────────────────────────────────────────────────
|
||||||
export function HelpCenterFAB() {
|
export function HelpCenterFAB() {
|
||||||
|
const { tenantConfig } = useTenantConfig();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
|
|
||||||
const faqs = [
|
|
||||||
{
|
|
||||||
q: "Kenapa data Threats kosong (0)?",
|
|
||||||
a: "Jika indikator Threats menunjukkan angka 0, artinya jaringan Anda aman dalam rentang waktu yang dipilih. Sistem tidak mendeteksi adanya paket data yang cocok dengan profil serangan yang diketahui."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: "Apa perbedaan Events dan Threats?",
|
|
||||||
a: "Events adalah semua log aktivitas koneksi biasa yang tercatat di jaringan Anda (termasuk yang aman). Threats adalah sekumpulan Events khusus yang telah difilter dan diklasifikasikan sebagai aktivitas berbahaya oleh sistem Deep Packet Inspection (DPI)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: "Bagaimana cara membaca Heatmap Geo Traffic?",
|
|
||||||
a: "Warna biru muda menunjukkan trafik rendah, sedangkan warna biru pekat/tua menunjukkan volume trafik (download/upload) yang sangat tinggi dari/ke negara tersebut."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: "Berapa lama data disimpan?",
|
|
||||||
a: "Untuk menjaga performa sistem, database MongoDB dikonfigurasi untuk secara otomatis menghapus data telemetri jaringan yang usianya lebih dari 7 hari."
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -69,11 +64,10 @@ export function HelpCenterFAB() {
|
|||||||
aria-label="Help and Resources"
|
aria-label="Help and Resources"
|
||||||
>
|
>
|
||||||
<HelpCircle className="h-6 w-6" />
|
<HelpCircle className="h-6 w-6" />
|
||||||
{/* Pulse effect */}
|
<span className="absolute -inset-1 animate-ping rounded-full bg-blue-400 opacity-20" />
|
||||||
<span className="absolute -inset-1 animate-ping rounded-full bg-blue-400 opacity-20"></span>
|
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
{/* Drawer Overlay */}
|
{/* Drawer Overlay + Panel */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<>
|
<>
|
||||||
@@ -84,20 +78,19 @@ export function HelpCenterFAB() {
|
|||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="fixed inset-0 z-50 bg-background/40 backdrop-blur-sm"
|
className="fixed inset-0 z-50 bg-background/40 backdrop-blur-sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Drawer Panel */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ x: '100%' }}
|
initial={{ x: "100%" }}
|
||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: '100%' }}
|
exit={{ x: "100%" }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
className="fixed right-0 top-0 z-50 flex h-full w-full max-w-sm flex-col bg-card/95 backdrop-blur-xl border-l border-border shadow-2xl"
|
className="fixed right-0 top-0 z-50 flex h-full w-full max-w-sm flex-col bg-card/95 backdrop-blur-xl border-l border-border shadow-2xl"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-border/50 px-6 py-5">
|
<div className="flex items-center justify-between border-b border-border/50 px-6 py-5">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-foreground">Help & Resources</h2>
|
<h2 className="text-lg font-bold text-foreground">Help & Resources</h2>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">BackOne Guide & Documentation</p>
|
<p className="text-xs text-muted-foreground mt-0.5">User Guide & Documentation</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
@@ -108,82 +101,52 @@ export function HelpCenterFAB() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-8">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6">
|
||||||
|
|
||||||
{/* Search Bar */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search articles or FAQs..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="w-full rounded-xl border border-border bg-secondary/50 py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Getting Started Video Cards */}
|
{/* Full User Guide Banner */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||||
<PlayCircle className="h-4 w-4" /> Getting Started
|
<BookOpen className="h-4 w-4" /> Documentation
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<Link
|
||||||
<div className="group cursor-pointer space-y-2">
|
href="/help"
|
||||||
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-slate-800 dark:bg-slate-900 border border-border flex items-center justify-center group-hover:border-blue-500 transition-colors">
|
onClick={() => setIsOpen(false)}
|
||||||
<PlayCircle className="h-8 w-8 text-white/70 group-hover:text-blue-400 group-hover:scale-110 transition-all" />
|
className="flex items-center justify-between gap-3 rounded-xl border border-blue-500/30 bg-blue-600/10 px-4 py-3 hover:bg-blue-600/20 transition-colors group"
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
>
|
||||||
</div>
|
<div>
|
||||||
<p className="text-xs font-medium text-foreground line-clamp-2 leading-tight">Dashboard Overview Tour</p>
|
<p className="text-sm font-semibold text-blue-500 dark:text-blue-400">
|
||||||
|
Read Full User Guide
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Panduan membaca data per halaman, tab, dan kolom tabel
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="group cursor-pointer space-y-2">
|
<ArrowRight className="h-4 w-4 text-blue-500 shrink-0 group-hover:translate-x-0.5 transition-transform" />
|
||||||
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-slate-800 dark:bg-slate-900 border border-border flex items-center justify-center group-hover:border-blue-500 transition-colors">
|
</Link>
|
||||||
<PlayCircle className="h-8 w-8 text-white/70 group-hover:text-blue-400 group-hover:scale-110 transition-all" />
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs font-medium text-foreground line-clamp-2 leading-tight">How to Analyze Metadata</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Documentation Links */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
|
||||||
<BookOpen className="h-4 w-4" /> Quick Links
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<button className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-secondary transition-colors text-left">
|
|
||||||
<FileText className="h-4 w-4 text-blue-500" />
|
|
||||||
Read the Full Documentation
|
|
||||||
</button>
|
|
||||||
<button className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-secondary transition-colors text-left">
|
|
||||||
<FileText className="h-4 w-4 text-emerald-500" />
|
|
||||||
Integration with BackOne API
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAQs */}
|
{/* FAQs */}
|
||||||
<div className="space-y-3 pb-8">
|
<div className="space-y-2 pb-6">
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||||
<HelpCircle className="h-4 w-4" /> Frequently Asked Questions
|
<HelpCircle className="h-4 w-4" /> Frequently Asked Questions
|
||||||
</h3>
|
</h3>
|
||||||
<div className="rounded-xl border border-border/50 bg-card px-4">
|
<div className="rounded-xl border border-border/50 bg-card px-4">
|
||||||
{faqs.map((faq, index) => (
|
{FAQS.map((faq, index) => (
|
||||||
<FAQItem key={index} question={faq.q} answer={faq.a} />
|
<FAQItem key={index} question={faq.question} answer={faq.answer} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="border-t border-border/50 bg-secondary/30 p-4 text-center">
|
<div className="border-t border-border/50 bg-secondary/30 p-4 text-center">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
BackOne © 2026<br/>
|
{tenantConfig?.brandName || "DPI Dashboard"} © 2026
|
||||||
PT. Data Bisnis Solusi
|
<br />
|
||||||
|
{tenantConfig?.footerCopyright || "PT. Data Bisnis Solusi"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { getViewAsHeaders } from '@/lib/admin-api';
|
||||||
|
|
||||||
|
interface TenantConfig {
|
||||||
|
brandName: string;
|
||||||
|
brandLogo: string;
|
||||||
|
footerCopyright: string;
|
||||||
|
primaryColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenantConfigContextType {
|
||||||
|
tenantConfig: TenantConfig | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
refetchConfig: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TenantConfigContext = createContext<TenantConfigContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function TenantConfigProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [tenantConfig, setTenantConfig] = useState<TenantConfig | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchConfig = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
fetch('/api/dashboard/tenant-config', { headers: getViewAsHeaders() })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(json => {
|
||||||
|
if (json.ok && json.data) {
|
||||||
|
setTenantConfig({
|
||||||
|
brandName: json.data.brand_name,
|
||||||
|
brandLogo: json.data.brand_logo,
|
||||||
|
footerCopyright: json.data.footer_copyright,
|
||||||
|
primaryColor: json.data.primary_color || '#E11D48',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TenantConfigContext.Provider value={{ tenantConfig, isLoading, refetchConfig: fetchConfig }}>
|
||||||
|
{children}
|
||||||
|
</TenantConfigContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTenantConfig() {
|
||||||
|
const context = useContext(TenantConfigContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useTenantConfig must be used within a TenantConfigProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
|
||||||
const BASE_URL = process.env.BACKONE_BASE_URL || process.env.NETIFY_BASE_URL;
|
const BASE_URL = process.env.BACKONE_BASE_URL || process.env.NETIFY_BASE_URL;
|
||||||
const API_URL = BASE_URL ? `${BASE_URL}/assets/agents` : "https://manager.netify.ai/api/v1/assets/agents";
|
const API_URL = BASE_URL ? `${BASE_URL}/assets/agents` : "https://manager.netify.ai/api/v1/assets/agents";
|
||||||
@@ -55,11 +58,25 @@ export interface Agent {
|
|||||||
export async function getAgents(clientSiteUuid?: string): Promise<Agent[]> {
|
export async function getAgents(clientSiteUuid?: string): Promise<Agent[]> {
|
||||||
try {
|
try {
|
||||||
const API_KEY = getApiKey();
|
const API_KEY = getApiKey();
|
||||||
const SITE_UUID = clientSiteUuid || getSiteUuid();
|
let SITE_UUID = clientSiteUuid || getSiteUuid();
|
||||||
|
|
||||||
|
// Securely retrieve the user's actual role and site_uuid on the server side
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('token')?.value;
|
||||||
|
if (token) {
|
||||||
|
const decoded: any = jwt.verify(token, process.env.JWT_SECRET || 'super-secret-backone-key');
|
||||||
|
if (decoded && decoded.role === 'TENANT_ADMIN') {
|
||||||
|
// Force user's site_uuid to prevent TENANT_ADMIN from requesting other sites
|
||||||
|
SITE_UUID = decoded.site_uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (cookieErr) {
|
||||||
|
console.warn("Failed to retrieve or verify cookie token inside getAgents:", cookieErr);
|
||||||
|
}
|
||||||
|
|
||||||
// All agent data comes from MongoDB (single source of truth per Rule 13)
|
// All agent data comes from MongoDB (single source of truth per Rule 13)
|
||||||
if (API_KEY && API_KEY.startsWith("sk_db_")) {
|
if (API_KEY && API_KEY.startsWith("sk_db_")) {
|
||||||
const mongoose = require('mongoose');
|
|
||||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi';
|
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi';
|
||||||
|
|
||||||
// Connect to MongoDB if not already connected
|
// Connect to MongoDB if not already connected
|
||||||
@@ -67,18 +84,30 @@ export async function getAgents(clientSiteUuid?: string): Promise<Agent[]> {
|
|||||||
await mongoose.connect(MONGODB_URI);
|
await mongoose.connect(MONGODB_URI);
|
||||||
}
|
}
|
||||||
const db = mongoose.connection.db;
|
const db = mongoose.connection.db;
|
||||||
|
if (!db) {
|
||||||
|
throw new Error("Database connection not ready");
|
||||||
|
}
|
||||||
|
|
||||||
const filter: any = {};
|
const filter: any = { agent_uuid: { $ne: null } }; // exclude site-level aggregate rows
|
||||||
if (SITE_UUID) {
|
if (SITE_UUID) {
|
||||||
filter.site_uuid = SITE_UUID;
|
filter.site_uuid = SITE_UUID;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all agents from MongoDB summaries collection
|
// Get all agents from MongoDB summaries collection, deduplicated by agent_uuid
|
||||||
const agentSummaries = await db.collection('summaries')
|
const agentSummaries = await db.collection('summaries')
|
||||||
.aggregate([
|
.aggregate([
|
||||||
{ $match: filter },
|
{ $match: filter },
|
||||||
{ $sort: { timestamp: -1 } },
|
{ $sort: { timestamp: -1 } },
|
||||||
{ $group: { _id: '$agent_uuid', lastSeen: { $first: '$timestamp' }, label: { $first: '$agent_label' } } },
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$agent_uuid',
|
||||||
|
lastSeen: { $first: '$timestamp' },
|
||||||
|
label: { $first: '$agent_label' },
|
||||||
|
site_uuid: { $first: '$site_uuid' }, // capture the site this agent belongs to
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Final guard: only keep agents whose primary site matches the queried site
|
||||||
|
...(SITE_UUID ? [{ $match: { site_uuid: SITE_UUID } }] : []),
|
||||||
]).toArray();
|
]).toArray();
|
||||||
|
|
||||||
// Check for any flows recorded in the last 12 hours to determine online status
|
// Check for any flows recorded in the last 12 hours to determine online status
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { getViewAsHeaders } from './admin-api';
|
||||||
|
|
||||||
const API_BASE = '';
|
const API_BASE = '';
|
||||||
|
|
||||||
@@ -52,7 +53,10 @@ export function useCategoryDetail(
|
|||||||
const params = new URLSearchParams({ category, timeRange });
|
const params = new URLSearchParams({ category, timeRange });
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/api/dashboard/category-detail?${params}`,
|
`${API_BASE}/api/dashboard/category-detail?${params}`,
|
||||||
{ credentials: 'include' }
|
{
|
||||||
|
headers: getViewAsHeaders(),
|
||||||
|
credentials: 'include'
|
||||||
|
}
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const result: CategoryDetailResult = await response.json();
|
const result: CategoryDetailResult = await response.json();
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ export const REFRESH_INTERVAL_MS = 60 * 1000;
|
|||||||
* - stale-while-revalidate caching (no spinner if cached data is present)
|
* - stale-while-revalidate caching (no spinner if cached data is present)
|
||||||
* - auto-refresh every 5 minutes
|
* - auto-refresh every 5 minutes
|
||||||
*/
|
*/
|
||||||
export function useCtxFetch<T>(endpoint: string, timeRange: string, defaultValue: T): {
|
export function useCtxFetch<T>(endpoint: string, timeRange: string, defaultValue: T, extraParams?: Record<string, string>): {
|
||||||
data: T;
|
data: T;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
} {
|
} {
|
||||||
const cacheKey = `${endpoint}:${timeRange}`;
|
// Include extraParams in cache key so different date ranges are cached separately
|
||||||
|
const extraKey = extraParams ? ':' + new URLSearchParams(extraParams).toString() : '';
|
||||||
|
const cacheKey = `${endpoint}:${timeRange}${extraKey}`;
|
||||||
|
|
||||||
// Initial state reads from global memory cache if present
|
// Initial state reads from global memory cache if present
|
||||||
const [data, setData] = useState<T>(() => {
|
const [data, setData] = useState<T>(() => {
|
||||||
@@ -58,7 +60,7 @@ export function useCtxFetch<T>(endpoint: string, timeRange: string, defaultValue
|
|||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
fetcher<T>(endpoint, timeRange)
|
fetcher<T>(endpoint, timeRange, extraParams)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
const val = res ?? defaultValue;
|
const val = res ?? defaultValue;
|
||||||
globalCache[cacheKey] = val;
|
globalCache[cacheKey] = val;
|
||||||
@@ -71,7 +73,7 @@ export function useCtxFetch<T>(endpoint: string, timeRange: string, defaultValue
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [endpoint, timeRange, tick]);
|
}, [endpoint, timeRange, extraKey, tick]);
|
||||||
|
|
||||||
return { data, error, isLoading };
|
return { data, error, isLoading };
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { getViewAsHeaders } from './admin-api';
|
||||||
|
|
||||||
const API_BASE = '';
|
const API_BASE = '';
|
||||||
|
|
||||||
@@ -75,7 +76,10 @@ export function useMetadataDetail(
|
|||||||
const params = new URLSearchParams({ type, value, timeRange });
|
const params = new URLSearchParams({ type, value, timeRange });
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/api/dashboard/metadata-detail?${params}`,
|
`${API_BASE}/api/dashboard/metadata-detail?${params}`,
|
||||||
{ credentials: 'include' }
|
{
|
||||||
|
headers: getViewAsHeaders(),
|
||||||
|
credentials: 'include'
|
||||||
|
}
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const result: MetadataDetailResult = await response.json();
|
const result: MetadataDetailResult = await response.json();
|
||||||
|
|||||||
+26
-10
@@ -102,9 +102,21 @@ export function useTimeline(points: number = 60, agentUuid?: string) {
|
|||||||
return { data, error, isLoading };
|
return { data, error, isLoading };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTopApps(limit: number = 10) {
|
/** Build extra query params for explicit date range filtering (overrides sidebar timeRange). */
|
||||||
|
function buildDateParams(dateFrom?: string, dateTo?: string): Record<string, string> | undefined {
|
||||||
|
const from = dateFrom?.trim();
|
||||||
|
const to = dateTo?.trim();
|
||||||
|
if (!from && !to) return undefined;
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (from) params.date_from = from;
|
||||||
|
if (to) params.date_to = to;
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTopApps(limit: number = 10, dateFrom?: string, dateTo?: string) {
|
||||||
const { timeRange } = useTimeFilter();
|
const { timeRange } = useTimeFilter();
|
||||||
return useCtxFetch<AppStat[]>(`/apps?limit=${limit}`, timeRange, []);
|
const extraParams = buildDateParams(dateFrom, dateTo);
|
||||||
|
return useCtxFetch<AppStat[]>(`/apps?limit=${limit}`, timeRange, [], extraParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTopProtocols() {
|
export function useTopProtocols() {
|
||||||
@@ -117,14 +129,16 @@ export function useSslSans(limit: number = 50) {
|
|||||||
return useCtxFetch<SslSanStat[]>(`/ssl-subject-alt-names?limit=${limit}`, timeRange, []);
|
return useCtxFetch<SslSanStat[]>(`/ssl-subject-alt-names?limit=${limit}`, timeRange, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDevices(limit: number = 0) {
|
export function useDevices(limit: number = 0, dateFrom?: string, dateTo?: string) {
|
||||||
const { timeRange } = useTimeFilter();
|
const { timeRange } = useTimeFilter();
|
||||||
return useCtxFetch<DeviceStat[]>(`/devices?limit=${limit}`, timeRange, []);
|
const extraParams = buildDateParams(dateFrom, dateTo);
|
||||||
|
return useCtxFetch<DeviceStat[]>(`/devices?limit=${limit}`, timeRange, [], extraParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useThreats(limit: number = 0) {
|
export function useThreats(limit: number = 0, dateFrom?: string, dateTo?: string) {
|
||||||
const { timeRange } = useTimeFilter();
|
const { timeRange } = useTimeFilter();
|
||||||
return useCtxFetch<ThreatStat[]>(`/threats?limit=${limit}`, timeRange, []);
|
const extraParams = buildDateParams(dateFrom, dateTo);
|
||||||
|
return useCtxFetch<ThreatStat[]>(`/threats?limit=${limit}`, timeRange, [], extraParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAppCategories() {
|
export function useAppCategories() {
|
||||||
@@ -137,9 +151,10 @@ export function useContinents() {
|
|||||||
return useCtxFetch<ContinentStat[]>('/continents', timeRange, []);
|
return useCtxFetch<ContinentStat[]>('/continents', timeRange, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFlows(limit: number = 0) {
|
export function useFlows(limit: number = 5000, dateFrom?: string, dateTo?: string) {
|
||||||
const { timeRange } = useTimeFilter();
|
const { timeRange } = useTimeFilter();
|
||||||
return useCtxFetch<FlowStat[]>(`/flows?limit=${limit}`, timeRange, []);
|
const extraParams = buildDateParams(dateFrom, dateTo);
|
||||||
|
return useCtxFetch<FlowStat[]>(`/flows?limit=${limit}`, timeRange, [], extraParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCountries(limit: number = 0) {
|
export function useCountries(limit: number = 0) {
|
||||||
@@ -152,9 +167,10 @@ export function useDNS(limit: number = 0) {
|
|||||||
return useCtxFetch<DnsStat[]>(`/dns?limit=${limit}`, timeRange, []);
|
return useCtxFetch<DnsStat[]>(`/dns?limit=${limit}`, timeRange, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEvents(limit: number = 0) {
|
export function useEvents(limit: number = 0, dateFrom?: string, dateTo?: string) {
|
||||||
const { timeRange } = useTimeFilter();
|
const { timeRange } = useTimeFilter();
|
||||||
return useCtxFetch<EventStat[]>(`/events?limit=${limit}`, timeRange, []);
|
const extraParams = buildDateParams(dateFrom, dateTo);
|
||||||
|
return useCtxFetch<EventStat[]>(`/events?limit=${limit}`, timeRange, [], extraParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTlsVersions(limit: number = 10) {
|
export function useTlsVersions(limit: number = 10) {
|
||||||
|
|||||||
+11
-4
@@ -5,11 +5,17 @@ import { useState, useEffect } from 'react';
|
|||||||
import { getViewAsHeaders } from '../admin-api';
|
import { getViewAsHeaders } from '../admin-api';
|
||||||
import { DashboardSummary, TimelinePoint } from '../api-types';
|
import { DashboardSummary, TimelinePoint } from '../api-types';
|
||||||
|
|
||||||
export async function fetcher<T>(endpoint: string, timeRange?: string): Promise<T> {
|
export async function fetcher<T>(endpoint: string, timeRange?: string, extraParams?: Record<string, string>): Promise<T> {
|
||||||
let url = `/api/dashboard${endpoint}`;
|
let url = `/api/dashboard${endpoint}`;
|
||||||
if (timeRange) {
|
const queryParts: string[] = [];
|
||||||
const sep = url.includes('?') ? '&' : '?';
|
if (timeRange) queryParts.push(`timeRange=${encodeURIComponent(timeRange)}`);
|
||||||
url += sep + 'timeRange=' + timeRange;
|
if (extraParams) {
|
||||||
|
for (const [key, val] of Object.entries(extraParams)) {
|
||||||
|
if (val) queryParts.push(`${key}=${encodeURIComponent(val)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queryParts.length > 0) {
|
||||||
|
url += (url.includes('?') ? '&' : '?') + queryParts.join('&');
|
||||||
}
|
}
|
||||||
const viewAsHeaders = getViewAsHeaders();
|
const viewAsHeaders = getViewAsHeaders();
|
||||||
const res = await fetch(url, { headers: viewAsHeaders });
|
const res = await fetch(url, { headers: viewAsHeaders });
|
||||||
@@ -23,6 +29,7 @@ export async function fetcher<T>(endpoint: string, timeRange?: string): Promise<
|
|||||||
return json.data;
|
return json.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useDashboardSummary(timeRange?: string) {
|
export function useDashboardSummary(timeRange?: string) {
|
||||||
const [data, setData] = useState<DashboardSummary | null>(null);
|
const [data, setData] = useState<DashboardSummary | null>(null);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
// src/lib/helpContent.ts
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Centralized content for the User Guide & Help Center.
|
||||||
|
// Used by both HelpCenterFAB (quick FAQs) and the /help page (full guide).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FAQItem {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableColumn {
|
||||||
|
column: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageGuide {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
icon: string;
|
||||||
|
summary: string;
|
||||||
|
sections: {
|
||||||
|
heading: string;
|
||||||
|
content?: string;
|
||||||
|
columns?: TableColumn[];
|
||||||
|
items?: { label: string; description: string }[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FAQ (used in HelpCenterFAB quick panel) ──────────────────────────────────
|
||||||
|
export const FAQS: FAQItem[] = [
|
||||||
|
{
|
||||||
|
question: "Kenapa data Threats kosong (0)?",
|
||||||
|
answer:
|
||||||
|
"Jika indikator Threats menunjukkan angka 0, artinya jaringan Anda aman dalam rentang waktu yang dipilih. Sistem tidak mendeteksi adanya paket data yang cocok dengan profil serangan yang diketahui.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Apa perbedaan Events dan Threats?",
|
||||||
|
answer:
|
||||||
|
"Events adalah semua log aktivitas koneksi biasa yang tercatat di jaringan (termasuk yang aman). Threats adalah sekumpulan Events khusus yang telah difilter dan diklasifikasikan sebagai aktivitas berbahaya oleh sistem Deep Packet Inspection (DPI).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Bagaimana cara membaca Heatmap Geo Traffic?",
|
||||||
|
answer:
|
||||||
|
"Warna biru muda menunjukkan trafik rendah, sedangkan warna biru pekat/tua menunjukkan volume trafik (download/upload) yang sangat tinggi dari/ke negara tersebut.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Berapa lama data disimpan?",
|
||||||
|
answer:
|
||||||
|
"Database sistem dikonfigurasi untuk secara otomatis menghapus data telemetri jaringan yang usianya lebih dari 7 hari untuk menjaga performa sistem.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Apa arti indikator Online/Offline pada agen?",
|
||||||
|
answer:
|
||||||
|
"Agen berstatus Online jika berhasil mengirim data dalam 12 jam terakhir. Agen Offline berarti tidak ada paket data yang diterima melebihi batas waktu tersebut.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Mengapa Download lebih besar dari Upload?",
|
||||||
|
answer:
|
||||||
|
"Ini adalah kondisi normal. Pengguna di jaringan umumnya mengunduh (streaming, web browsing) jauh lebih banyak daripada mengunggah. Jika Upload lebih besar, hal itu bisa mengindikasikan data exfiltration dan perlu diselidiki.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Full Page Guides (used in /help page) ────────────────────────────────────
|
||||||
|
export const PAGE_GUIDES: PageGuide[] = [
|
||||||
|
{
|
||||||
|
id: "overview",
|
||||||
|
title: "Overview",
|
||||||
|
url: "/",
|
||||||
|
icon: "🏠",
|
||||||
|
summary: "Ringkasan kondisi jaringan secara keseluruhan dalam satu tampilan.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "Kartu KPI (Header)",
|
||||||
|
columns: [
|
||||||
|
{ column: "Total Flows", description: "Jumlah sesi koneksi jaringan yang terpantau dalam rentang waktu aktif." },
|
||||||
|
{ column: "Total Download", description: "Akumulasi data yang diterima oleh semua perangkat di site aktif." },
|
||||||
|
{ column: "Total Upload", description: "Akumulasi data yang dikirim keluar dari site aktif." },
|
||||||
|
{ column: "Active Devices", description: "Jumlah perangkat unik yang terdeteksi aktif dalam rentang waktu aktif." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Globe Map",
|
||||||
|
content:
|
||||||
|
"Peta bola dunia interaktif. Garis bercahaya merepresentasikan arus trafik dari site Anda ke negara tujuan. Warna garis menunjukkan intensitas trafik. Hover ke garis untuk melihat nama negara dan volume data. Klik dan seret untuk memutar globe.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Top Applications & Top Protocols",
|
||||||
|
content:
|
||||||
|
"Widget di bawah globe menampilkan 7 aplikasi dan protokol dengan volume trafik tertinggi. Bar horizontal merepresentasikan perbandingan volume relatif antar entri.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "agents",
|
||||||
|
title: "Agents Inventory",
|
||||||
|
url: "/agents",
|
||||||
|
icon: "📡",
|
||||||
|
summary: "Daftar dan status semua Network Agent yang terdaftar di site Anda.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "Kartu Statistik",
|
||||||
|
columns: [
|
||||||
|
{ column: "Total Agents", description: "Jumlah Network Agent yang terdaftar di site aktif." },
|
||||||
|
{ column: "Online Agents", description: "Agen yang berhasil mengirim data dalam 12 jam terakhir." },
|
||||||
|
{ column: "Offline Agents", description: "Agen yang tidak terdeteksi aktif." },
|
||||||
|
{ column: "Avg. Historical Uptime", description: "Rata-rata persentase waktu aktif seluruh agen sepanjang data 7 hari tersimpan." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Agent Location & Topology Map",
|
||||||
|
content:
|
||||||
|
"Peta Indonesia interaktif. Titik hijau = agen Online. Titik merah = agen Offline. Garis biru = aliran trafik antar agen yang terdeteksi. Hover ke titik untuk melihat detail agen. Hover ke garis untuk melihat detail flow (agen asal, tujuan, volume data). Panel biru di bawah peta menampilkan agen yang belum memiliki koordinat GPS — klik chip agen untuk menambahkan koordinat secara manual.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Tabel: Provisioned Network Agents",
|
||||||
|
columns: [
|
||||||
|
{ column: "UUID / Serial", description: "Identitas unik perangkat agen hardware." },
|
||||||
|
{ column: "Label", description: "Nama lokasi atau deskripsi yang diberikan untuk agen ini." },
|
||||||
|
{ column: "Provisioned", description: "Apakah agen sudah diaktifkan dan didaftarkan di sistem." },
|
||||||
|
{ column: "Activated", description: "Apakah agen sudah terhubung dan pernah mengirim data." },
|
||||||
|
{ column: "Status", description: "Online (hijau) atau Offline (merah) beserta waktu terakhir terlihat." },
|
||||||
|
{ column: "Historical Uptime", description: "Persentase waktu aktif selama 7 hari data tersimpan." },
|
||||||
|
{ column: "Data Size", description: "Total ukuran data yang dikumpulkan agen ini di database sistem." },
|
||||||
|
{ column: "Actions", description: "Tombol aksi: Detail, Koordinat GPS, Visibilitas, Delete." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "flows",
|
||||||
|
title: "Flows",
|
||||||
|
url: "/flows",
|
||||||
|
icon: "🔀",
|
||||||
|
summary: "Daftar semua sesi koneksi jaringan aktif yang terdeteksi oleh DPI.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "Filter Bar",
|
||||||
|
content:
|
||||||
|
"Filter di bagian atas tabel: Source IP, Destination IP, Protocol, dan App / Domain. Kombinasikan beberapa filter untuk mempersempit hasil pencarian.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Tabel: Active Flows",
|
||||||
|
columns: [
|
||||||
|
{ column: "#", description: "Nomor urut baris dalam halaman saat ini." },
|
||||||
|
{ column: "Last Seen", description: "Waktu terakhir paket dari sesi ini diterima (zona waktu Indonesia)." },
|
||||||
|
{ column: "Flow ID", description: "Identitas unik sesi koneksi yang dibuat oleh DPI." },
|
||||||
|
{ column: "Src IP", description: "Alamat IP sumber — perangkat atau server yang memulai koneksi." },
|
||||||
|
{ column: "Dst IP", description: "Alamat IP tujuan. Hover untuk melihat nama perangkat jika diketahui." },
|
||||||
|
{ column: "Dst Port", description: "Port tujuan. Hover untuk melihat nama layanan (443=HTTPS, 53=DNS, dll.)." },
|
||||||
|
{ column: "Protocol", description: "Protokol jaringan yang digunakan pada sesi ini (TCP, UDP, QUIC, dll.)." },
|
||||||
|
{ column: "App / Domain", description: "Aplikasi atau nama domain yang teridentifikasi oleh mesin DPI." },
|
||||||
|
{ column: "Download", description: "Volume data yang diterima dari tujuan ke sumber (dalam KB/MB)." },
|
||||||
|
{ column: "Upload", description: "Volume data yang dikirim dari sumber ke tujuan." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "apps",
|
||||||
|
title: "Apps",
|
||||||
|
url: "/apps",
|
||||||
|
icon: "📦",
|
||||||
|
summary: "Daftar semua aplikasi yang teridentifikasi oleh DPI beserta volume trafik.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "Tabel: Top Applications",
|
||||||
|
columns: [
|
||||||
|
{ column: "Application", description: "Nama aplikasi yang teridentifikasi (YouTube, Zoom, WhatsApp, dll.)." },
|
||||||
|
{ column: "Download", description: "Total data yang diunduh melalui aplikasi ini dalam rentang waktu aktif." },
|
||||||
|
{ column: "Upload", description: "Total data yang diunggah melalui aplikasi ini." },
|
||||||
|
{ column: "Total Traffic", description: "Gabungan download + upload untuk aplikasi ini." },
|
||||||
|
{ column: "Devices", description: "Jumlah perangkat unik yang menggunakan aplikasi ini." },
|
||||||
|
{ column: "Flows", description: "Jumlah sesi koneksi aktif dari aplikasi ini." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "devices",
|
||||||
|
title: "Devices",
|
||||||
|
url: "/devices",
|
||||||
|
icon: "💻",
|
||||||
|
summary: "Inventaris semua perangkat yang terdeteksi aktif di jaringan.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "Tabel: Device Inventory",
|
||||||
|
columns: [
|
||||||
|
{ column: "IP Address", description: "Alamat IP perangkat di jaringan lokal." },
|
||||||
|
{ column: "MAC Address", description: "Identitas fisik kartu jaringan perangkat (hardware address)." },
|
||||||
|
{ column: "Hostname", description: "Nama perangkat jika tersedia dari resolver DNS lokal." },
|
||||||
|
{ column: "Agent", description: "Network Agent yang mendeteksi dan memantau perangkat ini." },
|
||||||
|
{ column: "Last Seen", description: "Kapan terakhir kali perangkat ini aktif mengirim atau menerima trafik." },
|
||||||
|
{ column: "Download / Upload", description: "Volume trafik total dari dan ke perangkat ini." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dns",
|
||||||
|
title: "DNS",
|
||||||
|
url: "/dns",
|
||||||
|
icon: "🌐",
|
||||||
|
summary: "Log query DNS yang terdeteksi di jaringan.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "Tabel: DNS Queries",
|
||||||
|
columns: [
|
||||||
|
{ column: "Domain", description: "Nama domain yang di-query oleh perangkat di jaringan." },
|
||||||
|
{ column: "Query Type", description: "Jenis query DNS: A (IPv4), AAAA (IPv6), MX (mail), CNAME (alias), dll." },
|
||||||
|
{ column: "Client IP", description: "Alamat IP perangkat yang melakukan query DNS ini." },
|
||||||
|
{ column: "Response", description: "Alamat IP yang dikembalikan oleh server DNS sebagai jawaban." },
|
||||||
|
{ column: "Count", description: "Frekuensi query domain ini dalam rentang waktu aktif." },
|
||||||
|
{ column: "Last Seen", description: "Waktu terakhir query domain ini terjadi." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "network-intelligence",
|
||||||
|
title: "Traffic Categories",
|
||||||
|
url: "/network-intelligence",
|
||||||
|
icon: "📊",
|
||||||
|
summary: "Klasifikasi trafik berdasarkan kategori aplikasi hasil analisis DPI.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "Kategori Utama",
|
||||||
|
content:
|
||||||
|
"Menampilkan bagan distribusi trafik per kategori utama seperti Streaming, Social Media, Web, Email, Cloud, File Transfer, dll. serta visualisasi proporsi trafik menggunakan Pie Chart.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Tabel Kategori Aplikasi",
|
||||||
|
columns: [
|
||||||
|
{ column: "Category", description: "Nama kategori aplikasi jaringan yang diidentifikasi." },
|
||||||
|
{ column: "Download", description: "Volume data yang diunduh pada kategori ini." },
|
||||||
|
{ column: "Upload", description: "Volume data yang diunggah pada kategori ini." },
|
||||||
|
{ column: "Total Traffic", description: "Gabungan volume download dan upload." },
|
||||||
|
{ column: "Devices", description: "Jumlah perangkat unik yang menggunakan kategori ini." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dpi-analytics",
|
||||||
|
title: "DPI MetaData",
|
||||||
|
url: "/dpi-analytics",
|
||||||
|
icon: "🔌",
|
||||||
|
summary: "Analisis metadata mentah hasil inspeksi paket mendalam (DPI) dan sidik jari perangkat.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "Tab: Hostnames & Servers",
|
||||||
|
content: "Log domain eksternal, IP server, port, dan agen pendeteksi. Membantu mengidentifikasi tujuan koneksi perangkat.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Tab: Client Fingerprints",
|
||||||
|
content: "Menampilkan data sidik jari sistem operasi (Discovery OS), dhcpFingerprints, dan HTTP User Agent untuk mengidentifikasi jenis perangkat yang aktif.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Tab: Specific Protocols",
|
||||||
|
content: "Log metadata protokol khusus seperti versi server SSH yang digunakan dan Bittorrent Hashes untuk mengaudit torrenting.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Tabel Detail Metadata",
|
||||||
|
columns: [
|
||||||
|
{ column: "Value / OS / Domain", description: "Nilai metadata, nama domain, User Agent, atau nama sistem operasi." },
|
||||||
|
{ column: "Details / Version", description: "Informasi detail tambahan (seperti versi SSH atau detail OS)." },
|
||||||
|
{ column: "Count", description: "Frekuensi kemunculan metadata ini di jaringan." },
|
||||||
|
{ column: "Last Seen", description: "Waktu terakhir metadata ini terdeteksi di database." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "geography",
|
||||||
|
title: "Geo Traffic",
|
||||||
|
url: "/geography",
|
||||||
|
icon: "🗺️",
|
||||||
|
summary: "Distribusi trafik berdasarkan negara tujuan koneksi.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "Tabel: Traffic by Country",
|
||||||
|
columns: [
|
||||||
|
{ column: "Country", description: "Negara tujuan koneksi yang terdeteksi oleh mesin geolokasi." },
|
||||||
|
{ column: "Flag", description: "Bendera negara sebagai identifikasi visual." },
|
||||||
|
{ column: "Download / Upload", description: "Volume trafik yang dikirim ke atau diterima dari negara tersebut." },
|
||||||
|
{ column: "Flows", description: "Jumlah sesi koneksi aktif yang menuju negara tersebut." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "intelligence",
|
||||||
|
title: "Threat Intelligence",
|
||||||
|
url: "/intelligence",
|
||||||
|
icon: "🛡️",
|
||||||
|
summary: "Deteksi ancaman keamanan jaringan secara otomatis oleh sistem DPI.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "Kategori Ancaman (Sidebar Kiri)",
|
||||||
|
items: [
|
||||||
|
{ label: "Crypto Mining", description: "Perangkat yang terdeteksi menambang cryptocurrency tanpa izin." },
|
||||||
|
{ label: "Botnet Command & Control", description: "Koneksi ke server Command and Control yang diketahui berbahaya." },
|
||||||
|
{ label: "Malware", description: "Trafik yang cocok dengan signature malware yang terdaftar." },
|
||||||
|
{ label: "Phishing", description: "Akses ke domain yang teridentifikasi sebagai situs phishing." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Tabel Detail Ancaman",
|
||||||
|
columns: [
|
||||||
|
{ column: "Threat Level", description: "Tingkat keparahan: Critical, High, Medium, atau Low." },
|
||||||
|
{ column: "Source IP", description: "Alamat IP perangkat di jaringan Anda yang terlibat." },
|
||||||
|
{ column: "Destination", description: "IP atau domain tujuan yang teridentifikasi berbahaya." },
|
||||||
|
{ column: "First Seen", description: "Kapan pertama kali ancaman ini terdeteksi dalam database." },
|
||||||
|
{ column: "Last Seen", description: "Kapan terakhir kali aktivitas mencurigakan ini terpantau." },
|
||||||
|
{ column: "Evidence", description: "Detail teknis yang menjadi dasar sistem mengklasifikasikan ini sebagai ancaman." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "events",
|
||||||
|
title: "Events",
|
||||||
|
url: "/events",
|
||||||
|
icon: "📋",
|
||||||
|
summary: "Log semua kejadian penting yang dicatat oleh sistem.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "Tabel: Event Log",
|
||||||
|
columns: [
|
||||||
|
{ column: "Timestamp", description: "Waktu kejadian terjadi dalam zona waktu Indonesia." },
|
||||||
|
{ column: "Severity", description: "Tingkat keparahan: Info, Warning, atau Critical." },
|
||||||
|
{ column: "Agent", description: "Network Agent yang mendeteksi dan mencatat kejadian ini." },
|
||||||
|
{ column: "Description", description: "Deskripsi singkat kejadian yang terjadi." },
|
||||||
|
{ column: "Details", description: "Klik untuk membuka panel detail lengkap kejadian." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "security-audit",
|
||||||
|
title: "Encryption Audit (TLS)",
|
||||||
|
url: "/security-audit",
|
||||||
|
icon: "🔐",
|
||||||
|
summary: "Audit keamanan enkripsi TLS di seluruh jaringan yang terpantau.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "Tabel: TLS Versions",
|
||||||
|
columns: [
|
||||||
|
{ column: "Version", description: "Versi TLS yang digunakan (TLS 1.0, 1.1, 1.2, 1.3)." },
|
||||||
|
{ column: "Count", description: "Jumlah koneksi yang menggunakan versi ini dalam rentang waktu aktif." },
|
||||||
|
{ column: "Risk", description: "TLS 1.0/1.1 = Berisiko Tinggi. TLS 1.2 = Sedang. TLS 1.3 = Aman." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Tabel: TLS Ciphers",
|
||||||
|
columns: [
|
||||||
|
{ column: "Cipher Suite", description: "Nama algoritma enkripsi yang digunakan pada koneksi TLS." },
|
||||||
|
{ column: "Strength", description: "Kekuatan enkripsi: Strong, Weak, atau Insecure." },
|
||||||
|
{ column: "Count", description: "Frekuensi penggunaan cipher ini dalam rentang waktu aktif." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "network-infrastructure",
|
||||||
|
title: "Topology (Infrastructure)",
|
||||||
|
url: "/network-infrastructure",
|
||||||
|
icon: "🌐",
|
||||||
|
summary: "Visualisasi dan rincian topologi fisik, logis, dan geografis dari infrastruktur jaringan.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "Tab: Routing & Geography",
|
||||||
|
content: "Melihat statistik wilayah (Top Regions) dan perkotaan (Top Cities) asal/tujuan pengiriman trafik jaringan.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Tab: Interfaces & VLANs",
|
||||||
|
content: "Daftar antarmuka jaringan fisik (Interfaces) dan penanda VLAN (VLAN Tags) yang aktif mengalirkan data.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Tab: IP & MAC",
|
||||||
|
content: "Daftar alamat IP remote teraktif, statistik versi protokol IP (IPv4 vs IPv6), dan pemakaian bandwidth berdasarkan MAC Address lokal.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Tab: Flow Profiles",
|
||||||
|
content: "Log karakteristik aliran data berdasarkan jenis flow (Flow Types) dan sumber asal flow (Flow Origins) seperti Local, Inbound, Outbound.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lookup",
|
||||||
|
title: "Application Database (Lookup)",
|
||||||
|
url: "/lookup",
|
||||||
|
icon: "🔍",
|
||||||
|
summary: "Katalog referensi global database aplikasi yang dapat dikenali oleh sistem DPI.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "Katalog & Pencarian",
|
||||||
|
content: "Gunakan bar pencarian di bagian atas untuk menemukan aplikasi tertentu berdasarkan nama, atau gunakan dropdown filter kategori untuk menyaring daftar aplikasi.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Detail Aplikasi (Modal)",
|
||||||
|
content: "Klik salah satu card aplikasi untuk membuka modal informasi lengkap yang merinci deskripsi aplikasi, tingkat risiko keamanan (Risk Level), port standar (Default Port), serta protokol transport yang digunakan.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Global UI Conventions ─────────────────────────────────────────────────────
|
||||||
|
export const UI_CONVENTIONS = [
|
||||||
|
{ symbol: "● (hijau)", meaning: "Status aktif / Online / Kondisi aman" },
|
||||||
|
{ symbol: "● (merah)", meaning: "Status tidak aktif / Offline / Berisiko" },
|
||||||
|
{ symbol: "● (kuning/amber)", meaning: "Perlu perhatian / Warning" },
|
||||||
|
{ symbol: "Teks terpotong (...)", meaning: "Hover ke teks untuk melihat nilai lengkap" },
|
||||||
|
{ symbol: "KB / MB / GB", meaning: "Satuan ukuran data (1 MB = 1.000 KB)" },
|
||||||
|
{ symbol: "Format waktu WIB/WITA/WIT", meaning: "Semua waktu ditampilkan dalam zona waktu Indonesia" },
|
||||||
|
{ symbol: "Pagination < 1/N >", meaning: "Navigasi halaman — setiap halaman menampilkan 50 baris data" },
|
||||||
|
];
|
||||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user