ntopng/http_src/vue/page-alerts-graph.vue

1577 lines
No EOL
64 KiB
Vue

<template>
<div class="dashboard-container bg-light">
<!-- Main Content -->
<div class="row g-4">
<!-- Filters Card - Vertically stacked -->
<div class="range-container d-flex flex-wrap">
<div class="card-body w-100 range-picker d-flex m-auto flex-wrap">
<RangePicker ref="range_picker" id="range-picker" :enable_refresh="true"
:disabled_date_picker="false" min_time_interval_id="5_min" :round_time="true">
<template v-slot:extra_range_buttons>
<div class="ms-4 d-flex align-items-center ms-2">
<label class="text-nowrap fw-semibold me-1"> {{
_i18n("map_page.asset_in_edges")
}} </label>
<input ref="slider_min_incoming_edges" type="range" class="form-range" min="0"
max="1000" v-model="minIncomingEdges" data-bs-toggle="tooltip"
data-bs-placement="top" :title="minIncomingEdges" />
</div>
<div class="ms-4 d-flex align-items-center ms-2">
<label class="text-nowrap fw-semibold me-1"> {{
_i18n("map_page.asset_out_edges") }} </label>
<input ref="slider_min_outgoing_edges" type="range" class="form-range" min="0"
max="1000" v-model="minOutgoingEdges" data-bs-toggle="tooltip"
data-bs-placement="top" :title="minOutgoingEdges" />
</div>
<div class="ms-4 d-flex align-items-center">
<div class="w-100">
<div class="d-flex">
<input type="text" class="form-control form-control-sm"
:class="{ 'is-invalid': nodeNotFoundMessage }" v-model="searchNodeId"
placeholder="Center on IP" @keyup.enter="findNode">
<button class="btn btn-sm btn-primary" @click="findNode">
<i class="fas fa-search"></i>
</button>
</div>
<div v-if="nodeNotFoundMessage" class="invalid-feedback d-block">
Host not present
</div>
</div>
</div>
</template>
</RangePicker>
</div>
</div>
<!-- Graph Visualization Section - Full width when no node selected -->
<div class="col-lg-8">
<div class="card shadow-sm h-100">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0 fw-bold">{{ _i18n("alert.graph.alerts_topology") }}</h5>
<button class="btn btn-sm btn-outline-secondary" @click="reset_filters">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<div class="card-body p-0">
<div ref="alerts_graph" class="graph-content d-flex justify-content-center align-items-center">
<Loading :isLoading="loading" :class="'mt-1'"></Loading>
<div v-if="no_data" class="d-flex justify-content-center align-items-center h-100">
<p class="text-center text-muted">{{ _i18n("alert.graph.no_data") }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Node Details Section - Only shown when a node is selected -->
<div class="col-lg-4">
<!-- Node or Alert Details Card -->
<div class="card shadow-sm h-100">
<!-- Conditional header based on what was clicked -->
<div class="card-header bg-white py-3">
<h5 class="card-title mb-0 fw-bold">
{{ lastClickedElementIsNode ? _i18n("alert.graph.node_details") : "Alert Details" }}
</h5>
</div>
<Loading :isLoading="hostDataLoading"></Loading>
<div v-if="!hostDataLoading" class="card-body">
<!-- Node Details Section -->
<div v-if="lastClickedElementIsNode" class="node-details">
<div class="mb-4">
<h6 class="fw-bold fs-5 text-black">
<!-- Display host IP and hostname if different than IP -->
<i class='fas fa-laptop'></i> {{ selectedNodeData?.host_info?.info?.ip || 'N/A'
}} <span v-if="selectedNodeData?.host_info?.info?.host_name && (selectedNodeData?.host_info?.info?.host_name != selectedNodeData?.host_info?.info?.ip)">
({{ selectedNodeData.host_info.info.host_name }})
</span>
</h6>
<div class="row g-3">
<div class="col-12">
<span class="detail-label">{{ _i18n("alert.graph.country") }}</span>
<img :src="'/dist/images/blank.gif'" class="flag"
:class="'flag-' + (selectedNodeData?.host_info?.info?.country?.toLowerCase() || '')" />
{{ selectedNodeData?.host_info?.info?.country || 'NA' }}
</div>
<div class="col-12">
<span class="detail-label">{{ _i18n("alert.graph.asn") }}</span>
<a v-if="selectedNodeData?.host_info?.info?.asn_name !== selectedNodeData?.host_info?.info?.ip"
:href="asnPageUrl" target="_blank" class="fw-bold">
{{ selectedNodeData?.host_info?.info?.asn_name }}
</a>
<span v-else class="fw-bold"> None </span>
</div>
<div class="col-12">
<span class="detail-label">{{ _i18n("alert.graph.live_flows") }}</span>
<a v-if="selectedNode" :href="activeFlows.live_flows_url" target="_blank"
class="fw-bold">
{{ activeFlows.recordsTotal }}
</a>
<a v-else class="disabled">0</a>
</div>
<div class="col-12">
<a :href="hist_flows_url" target="_blank" class="fw-bold">
<i class="fas fa-lg fa-chart-area me-1"> </i>
<span class="detail-label text-primary">{{
_i18n("alert.graph.hist_flows")
}}</span>
</a>
</div>
<div class="col-12">
<a :href="hist_alerts_url" target="_blank" class="text-danger fw-bold">
<i class="fa-solid fa-triangle-exclamation me-1"> </i>
<span class="detail-label text-primary">{{
_i18n("alert.graph.hist_alerts")
}} </span>
</a>
</div>
</div>
</div>
<ul class="nav nav-tabs" id="nodeRoleTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#client"
type="button">
{{ _i18n("alert.graph.as_client") }}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#server"
type="button">
{{ _i18n("alert.graph.as_server") }}
</button>
</li>
</ul>
<div class="tab-content pt-3">
<template v-for="role in ['client', 'server']" :key="role">
<div class="tab-pane fade" :class="{ 'show active': role === 'client' }" :id="role">
<div
v-if="selectedNodeData && selectedNodeData.host_info && selectedNodeData.host_info[role]">
<div class="detail-row">
<span class="detail-label">{{ _i18n("alert.graph.first_seen")
}}</span>
<span class="detail-value">{{
FormatterUtils.formatDateTime(selectedNodeData.host_info[role]?.first_seen)
|| '-' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ _i18n("alert.graph.last_seen")
}}</span>
<span class="detail-value">{{
FormatterUtils.formatDateTime(selectedNodeData.host_info[role]?.last_seen)
||
'-' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ _i18n("alert.graph.alerts_count")
}}</span>
<span class="detail-value">{{
formatterUtils.getFormatter("number")(selectedNodeData.host_info[role]?.alerts_count)
|| '-' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ _i18n("alert.graph.total_score")
}}</span>
<span class="detail-value">{{
formatterUtils.getFormatter("number")(selectedNodeData.host_info[role]?.total_score)
|| '-' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ _i18n("alert.graph.total_traffic")
}}</span>
<span class="detail-value">{{
formatterUtils.getFormatter("bytes")(selectedNodeData.host_info[role]?.total_traffic_bytes)
}}</span>
</div>
</div>
<div v-else>
<span class="detail-label">No alerts for {{ selectedNode }} as {{ role
}}</span>
</div>
<div v-if="selectedNodeData && selectedNodeData.host_info && selectedNodeData.host_info[role]"
class="alert-summary card bg-light mt-3">
<div class="card-body p-3">
<h6 class="card-subtitle mb-2 text-muted">{{
_i18n("alert.graph.alert_summary") }}</h6>
<div class="progress mb-3" style="height: 8px;">
<div v-for="(item, index) in selectedNodeData.severity_info?.[role]"
:key="index" class="progress-bar"
:style="{ width: item.percentage + '%', backgroundColor: item.severity_color }"
role="progressbar" data-bs-toggle="tooltip"
data-bs-placement="top"
:title="`${item.percentage.toFixed(2)}% ${item.severity}`">
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- Alert Details Section -->
<div v-else class="alert-details">
<div class="mb-4">
<div class="row g-3">
<div class="col-12">
<div class="detail-row">
<span class="detail-label fw-bold">
{{ _i18n("alert.graph.alert_type") }}
</span>
<span class="detail-value">
{{ selectedAlertData?.alert_type }}
</span>
</div>
<div class="detail-row">
<span class="detail-label fw-bold">
{{ _i18n("alert.graph.alert_count") }}
</span>
<span class="detail-value">
{{ selectedAlertData?.alerts_count }}
</span>
</div>
<div class="detail-row">
<span class="detail-label fw-bold">
{{ _i18n("alert.graph.country") }}
</span>
<span class="detail-value">
{{ selectedAlertData?.proto }}
</span>
</div>
<div class="detail-row">
<span class="detail-label fw-bold">
{{ _i18n("alert_entities.l7") }}
</span>
<span class="detail-value">
{{ selectedAlertData?.l7 }}
</span>
</div>
</div>
<div class="col-12">
<h6 class="fw-bold">
{{ _i18n("alert.graph.src_info") }}
</h6>
<div class="ms-2 mb-2">
<div class="detail-row">
<span class="detail-label fw-bold">
{{ _i18n("alert.graph.ip") }}
</span>
<span class="detail-value">
{{ selectedAlertData?.src_ip || 'N/A' }}
</span>
</div>
<div class="detail-row">
<span class="detail-label fw-bold">
{{ _i18n("alert.graph.country") }}
</span>
<span class="detail-value">
<img v-if="selectedAlertData?.src_country"
:src="'/dist/images/blank.gif'" class="flag"
:class="'flag-' + (selectedAlertData?.src_country?.toLowerCase() || '')" />
{{ selectedAlertData?.src_country || 'N/A' }}
</span>
</div>
<div class="detail-row">
<span class="detail-label fw-bold">
{{ _i18n("alert.graph.asn") }}
</span>
<span
v-if="selectedAlertData?.src_asn && selectedAlertData?.src_asn !== selectedAlertData?.src_ip"
class="detail-value fw-bold">
{{ selectedAlertData?.src_asn }}
</span>
<span v-else class="detail-value fw-bold">
N/A
</span>
</div>
</div>
</div>
<div class="col-12">
<h6 class="fw-bold">{{ _i18n("alert.graph.dst_info") }}</h6>
<div class="ms-2">
<div class="detail-row">
<span class="detail-label">{{ _i18n("alert.graph.ip") }}</span>
<span class="detail-value">{{ selectedAlertData?.dst_ip || 'N/A'
}}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ _i18n("alert.graph.country") }}</span>
<span class="detail-value">
<img v-if="selectedAlertData?.dst_country"
:src="'/dist/images/blank.gif'" class="flag"
:class="'flag-' + (selectedAlertData?.dst_country?.toLowerCase() || '')" />
{{ selectedAlertData?.dst_country || 'N/A' }}
</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ _i18n("alert.graph.asn") }}</span>
<span
v-if="selectedAlertData?.dst_asn && selectedAlertData?.dst_asn !== selectedAlertData?.dst_ip"
class="detail-value fw-bold">
{{ selectedAlertData?.dst_asn }}
</span>
<span v-else class="detail-value fw-bold">
N/A
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, computed, nextTick } from "vue";
import { ntopng_utility, ntopng_url_manager } from "../services/context/ntopng_globals_services.js";
import { default as RangePicker } from "./range-picker.vue";
import formatterUtils from "../utilities/formatter-utils";
import { default as Loading } from "./loading.vue";
import FormatterUtils from "../utilities/formatter-utils.js";
const _i18n = (t) => i18n(t);
const d3 = d3v7;
const props = defineProps({
context: Object,
});
// State data
const ifid = String(props.context.ifid);
const hostDataLoading = ref(true);
const alerts_graph = ref(null);
const range_picker = ref(null);
const loading = ref(true);
const no_data = ref(false);
const slider_min_incoming_edges = ref(null);
const slider_min_outgoing_edges = ref(null);
const last_url = ref();
const minIncomingEdges = ref(0); // minimum number of incoming edges (alerts) of a node
const minOutgoingEdges = ref(0); // minimum number of outgoing edges (alerts) of a node
// Selected node information (right div next to graph)
const selectedNodeData = ref({});
const selectedAlertData = ref({});
const selectedNode = ref(false);
const lastClickedElementIsNode = ref(true); // if true last clicked was a node, if false is an edge between nodes
const activeFlows = ref({ recordsTotal: 0, url: "#" });
const searchNodeId = ref('');
const nodeNotFoundMessage = ref(false); // if focus on a node does not find a node, show error
/* Computed URLs to get host information*/
const asnPageUrl = computed(() => {
return `${http_prefix}/lua/hosts_stats.lua?asn=${selectedNodeData.value?.host_info?.info?.asn || ''}&version=&network=&traffic_type=&mode=&pool=`;
});
const live_flows_url = computed(() => {
return `${http_prefix}/lua/flows_stats.lua?flowhosts_type=${selectedNode.value}%400&l4proto=&application=&alert_type=&qoe=&tcp_flow_state=&dscp=&traffic_type=&host_pool_id=&network=#`;
});
const hist_flows_url = computed(() => {
let epoch_begin = get_url_param("epoch_begin")
let epoch_end = get_url_param("epoch_end")
return `${http_prefix}/lua/pro/db_search.lua?ifid=${ifid}&epoch_begin=${epoch_begin}&epoch_end=${epoch_end}&aggregated=false&query_preset=&count=THROUGHPUT&ip=${selectedNode.value}%3Beq`;
});
const hist_alerts_url = computed(() => {
let epoch_begin = get_url_param("epoch_begin")
let epoch_end = get_url_param("epoch_end")
return `${http_prefix}/lua/alert_stats.lua?page=flow&epoch_begin=${epoch_begin}&epoch_end=${epoch_end}&status=any&ifid=${ifid}&query_preset=&count=&ip=${selectedNode.value}%3Beq`;
});
/**************************************/
// D3 Graph data
let links = [];
let nodes = [];
let resizeTimeout;
let clickTimer = null;
let lastClickedNode = null;
// highlight node function
let highlightNodeFn = null;
/**********************************************/
const applyFilters = async () => {
// Security check in order to not reload the component if the filters are the same
if (last_url.value === window.location.href) return;
let center_graph_on_ip = null;
last_url.value = window.location.href;
// Empty links and nodes to make request to backend
links = [];
nodes = [];
// Draw graph with new filters
await draw_graph(true, center_graph_on_ip);
};
/******************************************************************************/
/**************************** GRAPH FUNCTIONS ******************************* */
async function draw_graph(redraw = false, centerIP = null) {
loading.value = true;
try {
if (redraw) {
// remove svg if there was a new filter
d3.select(alerts_graph.value).select("svg").remove();
}
// remove old tooltips
$('.tooltip').remove();
$('[data-toggle="tooltip"]').tooltip('dispose');
// fetch data on first rendering
if (links.length === 0 && nodes.length === 0) {
const data = await get_links_and_nodes();
links = data.links;
nodes = data.nodes;
}
// redraw graph and links and nodes are not defined
if (redraw && links.length === 0 && nodes.length === 0) {
const data = await get_links_and_nodes();
links = data.links;
nodes = data.nodes;
if (nodes.length === 0) {
no_data.value = true;
loading.value = false;
hostDataLoading.value = false;
return;
}
}
const width = alerts_graph.value.clientWidth || alerts_graph.value.offsetWidth || alerts_graph.value.getBoundingClientRect().width;
const height = alerts_graph.value.clientHeight || 500;
// Create SVG with zoom behavior
const svg = d3.select(alerts_graph.value)
.append("svg")
.attr("width", width)
.attr("height", height)
.style("user-select", "none")
.style("-webkit-user-select", "none")
.style("-moz-user-select", "none")
.style("-ms-user-select", "none");
// Create main group for zoom transformations
const mainGroup = svg.append("g");
// Calculate alerts counts for each node
nodes.forEach(node => {
node.alert_count = links.filter(link => link.target.id === node.id || link.target === node.id ||
link.source.id === node.id || link.source === node.id).length;
});
// select as node the one with most alerts
const maxNode = nodes.reduce((prev, current) => {
return (prev && prev.alert_count > current.alert_count) ? prev : current;
}, nodes[0]);
selectedNode.value = maxNode.id;
// add filter to url
add_filter('ip', selectedNode.value);
await get_host_info();
activeFlows.value = await get_active_flows();
// Node color scale based on alert count
const nodeColorScale = d3.scaleSequential()
.domain([0, d3.max(nodes, d => d.alert_count) || 1])
.interpolator(d3.interpolateYlOrRd);
// Link color scale
const linkColorScale = d3.scaleThreshold()
.domain([1, 50, 100])
.range(["#E0E0E0", "#FFB74D", "#FF9800", "#FF8F00"]);
// Link color scale for highlighted paths - using more saturated colors
const highlightColorScale = d3.scaleThreshold()
.domain([1, 50, 100])
.range(["#1E88E5", "#1565C0", "#0D47A1", "#0A2472"]);
// compute nodes position
const simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id).distance(150))
.force("charge", d3.forceManyBody().strength(-500))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(30))
.force("x", d3.forceX(width / 2).strength(0.1))
.force("y", d3.forceY(height / 2).strength(0.1));
simulation.stop();
for (let i = 0; i < 300; ++i) simulation.tick();
// DFS init adjacency list
const adjacencyList = {};
nodes.forEach(node => {
adjacencyList[node.id] = [];
});
links.forEach(link => {
const sourceId = link.source.id || link.source;
const targetId = link.target.id || link.target;
adjacencyList[sourceId].push({ targetId, link });
});
// Find all outgoing paths from a node, when clicked
function findOutgoingPathsFromNode(sourceId) {
const pathLinks = new Set();
const visited = new Set();
function dfs(currentId) {
if (visited.has(currentId)) return;
visited.add(currentId);
// get neighbors of current node
const neighbors = adjacencyList[currentId] || [];
neighbors.forEach(neighbor => {
pathLinks.add(neighbor.link);
// iterate
dfs(neighbor.targetId);
});
}
// start dfs from node
dfs(sourceId);
return pathLinks;
}
// Highlight a node and its outgoing paths
function highlightNode(nodeId) {
// Reset all node styles
d3.selectAll(".node-group circle, .node-group path")
.attr("stroke", "#212121")
.attr("stroke-width", 1);
// Highlight selected node
d3.selectAll(".node-group")
.filter(d => d.id === nodeId)
.selectAll("circle, path")
.attr("stroke", "#FFC107")
.attr("stroke-width", 2);
// Reset all links to default style
d3.selectAll(".link")
.attr("style", d => `stroke: ${linkColorScale(d.weight)} !important`)
.attr("stroke-width", 8)
.attr("stroke-dasharray", null);
// Find all paths with the node as source
const outgoingPathLinks = findOutgoingPathsFromNode(nodeId);
// Highlight outgoing paths
d3.selectAll(".link")
.filter(d => outgoingPathLinks.has(d))
.attr("style", d => `stroke: ${highlightColorScale(d.weight)} !important`)
.attr("stroke-width", 10)
.attr("stroke-opacity", 4.0);
// Dashed lines, outgoing links
d3.selectAll(".link")
.attr("stroke-dasharray", link =>
(link.source.id === nodeId || link.source === nodeId) ? "5,5" : null);
}
// Store the highlight function for external access
highlightNodeFn = highlightNode;
// Replace the line-based links with path-based curved links
const link = mainGroup.append("g")
.selectAll("path")
.data(links)
.enter().append("path")
.attr("class", "link")
.attr("style", d => {
return `stroke: ${linkColorScale(d.weight)} !important`
})
.attr("stroke-opacity", 1)
.attr("fill", "none")
.attr("stroke-width", 4)
.attr("stroke-dasharray", null)
.attr("marker-end", "url(#arrow)")
.attr("d", d => {
const source = d.source;
const target = d.target;
// Calculate midpoint
const midX = (source.x + target.x) / 2;
const midY = (source.y + target.y) / 2;
// Calculate perpendicular offset
const dx = target.x - source.x;
const dy = target.y - source.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Only apply offset if points aren't too close
if (dist > 10) {
// Fixed offset - adjust based on your preference
const offset = 30;
// Calculate the offset coordinates
const offsetX = -dy * offset / dist;
const offsetY = dx * offset / dist;
// Return a simple curved path
return `M${source.x},${source.y} Q${midX + offsetX},${midY + offsetY} ${target.x},${target.y}`;
} else {
// For very close nodes, use a straight line
return `M${source.x},${source.y} L${target.x},${target.y}`;
}
})
.on("click", (event, d) => {
event.preventDefault();
// last clicked item is an edge
lastClickedElementIsNode.value = false;
// a link is an alert
selectedAlertData.value.alerts_count = d.weight;
selectedAlertData.value.alert_type = d.label.alert;
selectedAlertData.value.proto = d.label.protocol;
selectedAlertData.value.l7 = d.label.l7;
selectedAlertData.value.src_ip = d.source.id;
selectedAlertData.value.src_asn = d.source.src_asn;
selectedAlertData.value.src_country = d.source.src_country;
selectedAlertData.value.dst_ip = d.target.id;
selectedAlertData.value.dst_asn = d.target.dst_asn;
selectedAlertData.value.dst_country = d.target.dst_country;
});
// Create a map to track parallel links between the same nodes
const linkLookup = {};
// Update path positions with curved links
link.each(function (d) {
const sourceId = d.source.id || d.source;
const targetId = d.target.id || d.target;
const linkKey = `${sourceId}-${targetId}`;
const reverseLinkKey = `${targetId}-${sourceId}`;
// Track number of parallel links
linkLookup[linkKey] = linkLookup[linkKey] || [];
d.linkIndex = linkLookup[linkKey].length;
linkLookup[linkKey].push(d);
// Count total parallel links
const totalLinks = linkLookup[linkKey].length;
// Check if there are also reverse links
const reverseLinks = linkLookup[reverseLinkKey] || [];
const totalBidirectionalLinks = totalLinks + reverseLinks.length;
// Determine curve strength based on the number of links
// between the same source-target pair
const curveStrength = Math.min(50, Math.max(20, 15 * totalBidirectionalLinks));
// Calculate the curvature offset based on this link's index
const offset = (d.linkIndex - (totalLinks - 1) / 2) * (curveStrength / totalLinks);
// Determine curved path
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const dr = Math.sqrt(dx * dx + dy * dy);
// Calculate midpoint with an offset perpendicular to the straight line
const offsetX = -dy * offset / dr;
const offsetY = dx * offset / dr;
// Control point for the curve
const cpx = d.source.x + dx / 2 + offsetX;
const cpy = d.source.y + dy / 2 + offsetY;
// Create the path
const path = `M${d.source.x},${d.source.y} Q${cpx},${cpy} ${d.target.x},${d.target.y}`;
d3.select(this).attr("d", path);
});
// Update the tooltips
link.each(function (d) {
let tooltipContent = `<strong>${d.label.alert}</strong><br>`;
if (d.alert_info) tooltipContent += `Alert Info: ${d.alert_info}<br>`;
if (d.label.alerts_count) tooltipContent += `Alerts Count: ${d.label.alerts_count}<br>`;
if (d.label.avg_score) tooltipContent += `Avg Score: ${d.label.avg_score}<br>`;
if ((d.label.src_asn) && (d.source.src_asn !== d.source.id)) tooltipContent += `Src ASN: ${d.label.src_asn}<br>`;
if ((d.label.dst_asn) && (d.target.dst_asn !== d.target.id)) tooltipContent += `Dst ASN: ${d.label.dst_asn}<br>`;
if (d.label.src_country) {
tooltipContent += `Src Country: ${d.label.src_country} <img src='/dist/images/blank.gif' class='flag flag-${d.label.src_country.toLowerCase()}'><br>`;
}
if (d.label.dst_country) {
tooltipContent += `Dst Country: ${d.label.dst_country} <img src='/dist/images/blank.gif' class='flag flag-${d.label.dst_country.toLowerCase()}'><br>`;
}
tooltipContent += `L4 Proto: ${d.label.protocol}<br>L7 App: ${d.label.l7}`;
// Apply Bootstrap tooltip
$(this).tooltip({
title: tooltipContent,
html: true,
container: 'body',
placement: 'top'
});
});
mainGroup.append("defs").selectAll("marker")
.data(["arrow", "arrowDotted"])
.enter().append("marker")
.attr("id", d => d)
.attr("viewBox", "0 -5 10 10")
.attr("refX", 25)
.attr("refY", 0)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto")
.append("path")
.attr("d", d => d === "arrowDotted" ? "M0,-4L10,0L0,4" : "M0,-5L10,0L0,5")
.attr("fill", d => d === "arrowDotted" ? "#FF5722" : "#999");
const nodeGroup = mainGroup.append("g")
.selectAll("g")
.data(nodes)
.enter().append("g")
.attr("class", "node-group")
.attr("transform", d => `translate(${d.x}, ${d.y})`)
.call(drag())
.style("pointer-events", "all")
.on("pointerup", async (event, clicked_node) => {
event.stopPropagation();
event.preventDefault();
lastClickedElementIsNode.value = true;
selectedNode.value = clicked_node.id;
try {
// Highlight the selected node and its outgoing paths
highlightNode(clicked_node.id);
} catch (err) {
console.error("Error in updating visual:", err);
}
// Update URL and get host info
try {
// add filter to url
add_filter('ip', clicked_node.id);
await new Promise(resolve => setTimeout(resolve, 0));
await get_host_info();
activeFlows.value = await get_active_flows();
} catch (err) {
console.error("Error in URL/host update:", err);
}
}).on("dblclick", async function (event, clicked_node) {
event.preventDefault();
if (clickTimer) {
clearTimeout(clickTimer);
clickTimer = null;
}
lastClickedNode = null;
selectedNode.value = clicked_node.id;
// Filter links where the clicked node ID appears as source or destination
const filteredLinks = links.filter(link => {
const sourceId = link.source.id || link.source;
const targetId = link.target.id || link.target;
return sourceId === clicked_node.id || targetId === clicked_node.id;
});
// Extract the node IDs from filtered links
const nodeIds = new Set();
filteredLinks.forEach(link => {
nodeIds.add(link.source.id || link.source);
nodeIds.add(link.target.id || link.target);
});
// Filter nodes that are part of the filtered links
const filteredNodes = nodes.filter(node => nodeIds.has(node.id));
// Update global variables
nodes = filteredNodes;
links = filteredLinks;
// add filter to url
add_filter('ip', clicked_node.id);
await get_host_info();
activeFlows.value = await get_active_flows();
// Redraw graph with the new filtered data
await draw_graph(true, clicked_node.id);
});
// Add the node circles with color based on alert count
const nodeRadius = 10;
nodeGroup.each(function (d) {
const group = d3.select(this);
if (d.is_localhost) {
// Circle for local hosts
group.append("circle")
.attr("r", nodeRadius)
.attr("fill", nodeColorScale(d.alert_count))
.attr("stroke", "#212121")
.attr("stroke-width", 1);
} else {
// Triangle for remote hosts
group.append("path")
.attr("d", d3.symbol().type(d3.symbolTriangle).size(200)) // size controls area, tweak if needed
.attr("fill", nodeColorScale(d.alert_count))
.attr("stroke", "#212121")
.attr("stroke-width", 1);
}
});
nodeGroup.append("text")
.attr("x", -nodeRadius - 6)
.attr("y", 4)
.attr("text-anchor", "end")
.attr("font-size", "12px")
.text(d => d.name || d.id); // render resolved name or ip
// Bootstrap tooltips to nodes
nodeGroup.each(function (d) {
let total_alerts = d.incoming_count + d.outgoing_count;
$(this).tooltip({
title: `<strong>${d.name || d.id}</strong><br>
Total Alerts: ${total_alerts}<br>
Incoming: ${d.incoming_count}<br>
Outgoing: ${d.outgoing_count}`,
html: true,
container: 'body',
placement: 'top'
});
});
// Get node position and extent for minimap
const xExtent = d3.extent(nodes, d => d.x);
const yExtent = d3.extent(nodes, d => d.y);
// Add padding to the extents
const paddingFactor = 0.1;
const xPadding = (xExtent[1] - xExtent[0]) * paddingFactor || width * paddingFactor;
const yPadding = (yExtent[1] - yExtent[0]) * paddingFactor || height * paddingFactor;
const paddedXExtent = [xExtent[0] - xPadding, xExtent[1] + xPadding];
const paddedYExtent = [yExtent[0] - yPadding, yExtent[1] + yPadding];
// Get graph size
const graphWidth = paddedXExtent[1] - paddedXExtent[0];
const graphHeight = paddedYExtent[1] - paddedYExtent[0];
// Set max zoom level (1.0 for 1x)
const maxZoom = 6.0;
const minZoom = Math.max(0.1, Math.min(
width / graphWidth,
height / graphHeight
) * 0.9); // 90% of the scale
// Create zoom behavior with constraints
const zoomBehavior = d3.zoom()
.scaleExtent([minZoom, maxZoom])
.translateExtent([[paddedXExtent[0], paddedYExtent[0]], [paddedXExtent[1], paddedYExtent[1]]])
.on("zoom", (event) => {
mainGroup.attr("transform", event.transform);
});
svg.call(zoomBehavior);
// Store this in a global variable or access it later
window.graphZoomBehavior = zoomBehavior;
// If centerIP is provided, center the graph on that node
if (centerIP) {
centerOnNode(centerIP, svg, zoomBehavior, width, height);
} else {
// Center and scale the view to fit all nodes
const padding = 50;
const xSize = xExtent[1] - xExtent[0] + padding * 2;
const ySize = yExtent[1] - yExtent[0] + padding * 2;
const scale = Math.min(
maxZoom,
Math.max(minZoom, Math.min(width / xSize, height / ySize))
);
// Calculate center position
const tx = width / 2 - (xExtent[0] + xExtent[1]) / 2 * scale;
const ty = height / 2 - (yExtent[0] + yExtent[1]) / 2 * scale;
svg.transition().duration(750)
.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
}
// Highlight initially selected node, the node with most alerts
if (selectedNode.value && !redraw) {
highlightNode(selectedNode.value);
}
loading.value = false;
} catch (error) {
console.error("Error drawing graph:", error);
loading.value = false;
hostDataLoading.value = false;
} finally {
loading.value = false;
}
}
function centerOnNode(nodeId, svg, zoom, width, height) {
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
const x = node.x;
const y = node.y;
// Calculate the translation to center the node
const tx = width / 2 - x * zoom.scale();
const ty = height / 2 - y * zoom.scale();
svg.transition().duration(750)
.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(zoom.scale()));
}
function findNode() {
if (!searchNodeId.value) return;
const foundNode = nodes.find(node => ((node.id === searchNodeId.value) || (node.name === searchNodeId.value)));
if (foundNode) {
selectedNode.value = foundNode.id;
const svg = d3.select(alerts_graph.value).select("svg");
const zoom = window.graphZoomBehavior; // reuse the zoom behavior
const g = svg.select("g"); // assuming your nodes/links are inside a <g> tag
const newZoom = 3;
const svgNode = svg.node();
const width = svgNode.clientWidth || svgNode.getBoundingClientRect().width;
const height = svgNode.clientHeight || svgNode.getBoundingClientRect().height;
// First, apply scale
svg.transition().duration(300)
.call(zoom.scaleTo, newZoom)
.transition().duration(300)
.call(zoom.translateTo, foundNode.x, foundNode.y);
// Highlight the found node
if (highlightNodeFn) {
highlightNodeFn(foundNode.id);
}
nodeNotFoundMessage.value = false;
} else {
nodeNotFoundMessage.value = true;
}
}
function resetZoom() {
const svg = d3.select(alerts_graph.value).select("svg");
const zoom = window.graphZoomBehavior;
svg.transition()
.duration(500)
.call(zoom.transform, d3.zoomIdentity);
}
/******************************************************************************/
/****************************** API GETTERS ********************************* */
const get_alerts_data = async function () {
// Create url filters
let url = `${http_prefix}/lua/pro/rest/v2/get/alert/graph/alerts.lua?`;
url = create_url(url);
try {
let headers = {
"Content-Type": "application/json",
};
const rsp = await ntopng_utility.http_request(url, { method: "get", headers });
no_data.value = false;
return rsp;
} catch (err) {
console.error(err);
}
};
const get_host_info = async function () {
// Create url filters
hostDataLoading.value = true;
let url = `${http_prefix}/lua/pro/rest/v2/get/alert/graph/host_info.lua?`;
url = create_url(url);
try {
let headers = {
"Content-Type": "application/json",
};
const rsp = await ntopng_utility.http_request(url, { method: "get", headers });
if (rsp) {
no_data.value = false;
selectedNodeData.value = rsp
}
hostDataLoading.value = false;
return [];
} catch (err) {
console.error(err);
hostDataLoading.value = false;
}
};
const get_active_flows = async function () {
try {
let headers = {
"Content-Type": "application/json",
};
let url = `${http_prefix}/lua/rest/v2/get/flow/active_list.lua?start=0&length=10&map_search=&visible_columns=actions%2Clast_seen%2Cfirst_seen%2Cprotocol%2Cscore%2Cqoe%2Cflow%2Cthroughput%2Cbytes%2Cinfo&flowhosts_type=${selectedNode.value}%400&l4proto=&application=&alert_type=&qoe=&tcp_flow_state=&dscp=&traffic_type=&host_pool_id=&network=`;
const rsp = await ntopng_utility.http_request(url, { method: "get", headers }, false, true);
if (rsp && rsp.recordsTotal !== undefined) {
return { recordsTotal: rsp.recordsTotal, live_flows_url };
}
return { recordsTotal: 0, live_flows_url };
} catch (err) {
console.error(err);
return { recordsTotal: 0, url: "#" };
}
};
const get_links_and_nodes = async function () {
const data = await get_alerts_data();
const links = [];
const nodesDict = new Map();
const incomingAlertsCounts = new Map();
const outgoingAlertsCounts = new Map();
// compute incoming and outgoing count for each node
for (let alert of data) {
const src_ip = alert.src_ip;
const dst_ip = alert.dst_ip;
const alertCount = parseInt(alert.alerts_count);
// outgoing count for source IP (cli_ip)
const currentOutgoing = outgoingAlertsCounts.get(src_ip) || 0;
outgoingAlertsCounts.set(src_ip, currentOutgoing + alertCount);
// incoming count for target IP (srv_ip)
const currentIncoming = incomingAlertsCounts.get(dst_ip) || 0;
incomingAlertsCounts.set(dst_ip, currentIncoming + alertCount);
}
for (let alert of data) {
let link = {
source: alert.src_ip,
target: alert.dst_ip,
weight: parseInt(alert.avg_alert_score),
label: { alert_count: alert.alert_count, alert: alert.alert, avg_score: alert.avg_alert_score, src_asn: alert.src_asn, dst_asn: alert.dst_asn, src_country: alert.src_country, dst_country: alert.dst_country, protocol: alert.l4_proto, l7: alert.l7_app },
alert_info: alert.info
};
links.push(link);
// prepare node data
if (!nodesDict.has(alert.src_ip)) {
let node_data = {
id: alert.src_ip,
name: alert.src_ip,
src_asn: alert.src_asn,
src_country: alert.src_country,
is_localhost: alert.src_localhost === 1,
incoming_count: incomingAlertsCounts.get(alert.src_ip) || 0,
outgoing_count: outgoingAlertsCounts.get(alert.src_ip) || 0,
// total alerts count for node
alert_count: (incomingAlertsCounts.get(alert.src_ip) || 0) +
(outgoingAlertsCounts.get(alert.src_ip) || 0)
}
if (alert?.src_name) {
node_data["name"] = alert.src_name
}
nodesDict.set(alert.src_ip, node_data);
}
if (!nodesDict.has(alert.dst_ip)) {
let node_data = {
id: alert.dst_ip,
name: alert.dst_ip,
dst_asn: alert.dst_asn,
dst_country: alert.dst_country,
is_localhost: alert.dst_localhost === 1,
incoming_count: incomingAlertsCounts.get(alert.dst_ip) || 0,
outgoing_count: outgoingAlertsCounts.get(alert.dst_ip) || 0,
// total alerts count for node
alert_count: (incomingAlertsCounts.get(alert.dst_ip) || 0) +
(outgoingAlertsCounts.get(alert.dst_ip) || 0)
}
if (alert?.dst_name) {
node_data["name"] = alert.dst_name
}
nodesDict.set(alert.dst_ip, node_data);
}
}
const nodes = Array.from(nodesDict.values());
return { links, nodes };
};
/******************************************************************************/
/****************************** GUI HELPERS ********************************* */
// Handle resize event
function resize() {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
draw_graph(true);
}, 250);
}
function drag() {
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
function dragstarted(event, d) {
// Sync d.x and d.y with the actual position from the transform
const [x, y] = d3.select(this).attr("transform").match(/translate\(([^,]+),([^)]+)\)/).slice(1).map(Number);
d.x = x;
d.y = y;
$(this).tooltip('hide');
$(this).tooltip('disable');
d3.select(this).raise();
}
function dragged(event, d) {
d.x = event.x;
d.y = event.y;
// Move group
d3.select(this).attr("transform", `translate(${d.x}, ${d.y})`);
// Create a direct reference to the node being dragged
const draggedNode = d;
// Use a Map to group links by source-target pair
const linkGroups = new Map();
// First pass: group links by their source-target combinations
d3.selectAll(".link").each(function (linkData, i) {
if (!linkData) return;
// Extract source and target IDs
const sourceId = typeof linkData.source === 'object' ? linkData.source.id : linkData.source;
const targetId = typeof linkData.target === 'object' ? linkData.target.id : linkData.target;
// Only process links connected to the dragged node
if (sourceId === draggedNode.id || targetId === draggedNode.id) {
// Create a unique key for each source-target pair
const key = sourceId < targetId ?
`${sourceId}-${targetId}` :
`${targetId}-${sourceId}`;
if (!linkGroups.has(key)) {
linkGroups.set(key, []);
}
linkGroups.get(key).push({
element: this,
linkData: linkData,
sourceId: sourceId,
targetId: targetId
});
}
});
// Second pass: update each group of links with different offsets
let totalLinks = 0;
linkGroups.forEach((links, key) => {
totalLinks += links.length;
// For each group of links between the same nodes
links.forEach((pathInfo, groupIndex) => {
const element = pathInfo.element;
const linkData = pathInfo.linkData;
// Update source/target positions
if (typeof linkData.source === 'object' && linkData.source) {
if (linkData.source.id === draggedNode.id) {
linkData.source.x = draggedNode.x;
linkData.source.y = draggedNode.y;
}
}
if (typeof linkData.target === 'object' && linkData.target) {
if (linkData.target.id === draggedNode.id) {
linkData.target.x = draggedNode.x;
linkData.target.y = draggedNode.y;
}
}
// Get source and target positions
const sourceX = linkData.source.x !== undefined ? linkData.source.x : 0;
const sourceY = linkData.source.y !== undefined ? linkData.source.y : 0;
const targetX = linkData.target.x !== undefined ? linkData.target.x : 0;
const targetY = linkData.target.y !== undefined ? linkData.target.y : 0;
// Calculate path with varying offsets for multiple links between same nodes
const midX = (sourceX + targetX) / 2;
const midY = (sourceY + targetY) / 2;
const dx = targetX - sourceX;
const dy = targetY - sourceY;
const dist = Math.sqrt(dx * dx + dy * dy);
// Base offset
const baseOffset = 10;
// Vary offset by group index for multiple links between same nodes
// Links in the same group get progressively larger offsets
const offsetMultiplier = links.length > 1 ?
(1 + groupIndex * 0.5) : 1;
const offset = baseOffset * offsetMultiplier;
// Create path
let pathD;
if (dist > 10) {
const offsetX = -dy * offset / dist;
const offsetY = dx * offset / dist;
pathD = `M${sourceX},${sourceY} Q${midX + offsetX},${midY + offsetY} ${targetX},${targetY}`;
} else {
pathD = `M${sourceX},${sourceY} L${targetX},${targetY}`;
}
// Update path
d3.select(element).attr("d", pathD);
});
});
}
function dragended(event, d) {
// re-enable tooltip
$(this).tooltip('enable');
}
}
onMounted(async () => {
// Set default url parameters
init_url_params();
// Initially draw the graph
await draw_graph();
window.addEventListener("resize", resize);
// get active flows value
activeFlows.value = await get_active_flows();
// Init bootstrap tooltip
nextTick(() => {
NtopUtils.reloadBSTooltips();
});
const tooltipTriggerminIncomingEdges = new bootstrap.Tooltip(slider_min_incoming_edges.value, { trigger: 'manual' });
slider_min_incoming_edges.value.addEventListener('input', () => {
$(".tooltip-inner").text(minIncomingEdges.value)
slider_min_incoming_edges.value.setAttribute('data-bs-original-title', minIncomingEdges.value);
tooltipTriggerminIncomingEdges.show();
});
slider_min_incoming_edges.value.addEventListener('mouseup', () => {
applyFilters()
})
const tooltipTriggerminOutgoingEdges = new bootstrap.Tooltip(slider_min_outgoing_edges.value, { trigger: 'manual' });
slider_min_outgoing_edges.value.addEventListener('input', () => {
$(".tooltip-inner").text(minOutgoingEdges.value)
slider_min_outgoing_edges.value.setAttribute('data-bs-original-title', minOutgoingEdges.value);
tooltipTriggerminOutgoingEdges.show();
});
slider_min_outgoing_edges.value.addEventListener('mouseup', () => {
applyFilters()
})
last_url.value = window.location.href;
ntopng_events_manager.on_event_change('range_picker', ntopng_events.FILTERS_CHANGE, (new_status) => { applyFilters(); }, true);
ntopng_events_manager.on_event_change('range_picker', ntopng_events.EPOCH_CHANGE, (new_status) => { applyFilters(); }, true);
});
onBeforeUnmount(() => {
document.removeEventListener('click', () => { });
});
watch(minOutgoingEdges, (newValue) => {
let min_outgoing_edges = newValue;
add_filter('min_outgoing', min_outgoing_edges);
});
watch(minIncomingEdges, (newValue) => {
let min_incoming_edges = newValue;
add_filter('min_incoming', min_incoming_edges);
});
watch(searchNodeId, (newValue) => {
// reset zoom if no node is selected
if (newValue.length === 0) {
resetZoom();
}
});
function init_url_params() {
ntopng_url_manager.set_key_to_url("ifid", ifid);
// This is to retrieve all alerts and not filter on engaged or require attention
ntopng_url_manager.set_key_to_url("status", "any");
if (ntopng_url_manager.get_url_entry("epoch_begin") == null
|| ntopng_url_manager.get_url_entry("epoch_end") == null) {
let now = Date.now();
let default_epoch_begin = Number.parseInt((now - 1000 * 30 * 60) / 1000);
let default_epoch_end = Number.parseInt(now / 1000);
ntopng_url_manager.set_key_to_url("epoch_begin", default_epoch_begin);
ntopng_url_manager.set_key_to_url("epoch_end", default_epoch_end);
}
// initial filters
let score_greater_equal = minOutgoingEdges.value + ";gte";
ntopng_url_manager.set_key_to_url("score", score_greater_equal);
ntopng_url_manager.set_key_to_url("severity", "");
ntopng_url_manager.set_key_to_url("ip", "");
ntopng_url_manager.set_key_to_url("min_outgoing", 0);
ntopng_url_manager.set_key_to_url("min_incoming", 0);
}
function add_filter(filter, value) {
ntopng_url_manager.set_key_to_url(filter, value);
}
function reset_filters() {
minOutgoingEdges.value = 0;
minIncomingEdges.value = 0;
// get all url parameters
const currentParams = Object.keys(ntopng_url_manager.get_url_object());
// remove all parameters
ntopng_url_manager.delete_params(currentParams);
init_url_params();
applyFilters();
}
function get_url_param(param) {
let params = ntopng_url_manager.get_url_object();
for (const param_key in params) {
if (param === param_key) {
return params[param];
}
}
return null;
}
const get_extra_params_obj = () => {
let extra_params = ntopng_url_manager.get_url_object();
return extra_params;
};
const create_url = (url) => {
let req_params = get_extra_params_obj();
let params_inserted = 0;
for (let param in req_params) {
if (params_inserted > 0) {
url += '&';
}
url += `${param}=${encodeURIComponent(req_params[param])}`;
params_inserted += 1;
}
return url;
}
/******************************************************************************/
</script>
<style scoped>
.dashboard-container {
min-height: 60vh;
padding: 1.5rem;
}
.filter-panel {
border-radius: 8px;
border: none;
}
.graph-content {
width: 100%;
height: 100%;
min-height: 60vh;
}
.card {
border: none;
border-radius: 8px;
overflow: hidden;
}
.card-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
padding: 1rem 1.5rem;
}
.card-footer {
border-top: 1px solid rgba(0, 0, 0, 0.05);
padding: 0.75rem 1.5rem;
}
.detail-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.detail-label {
color: #6c757d;
font-weight: bold;
}
.alert-summary {
border-radius: 6px;
}
.dropdown-menu {
max-height: 200px;
overflow-y: auto;
border: none;
border-radius: 6px;
}
.dropdown-item {
padding: 0.5rem 1rem;
}
.dropdown-item:hover {
background-color: #f8f9fa;
}
.badge {
width: 12px;
height: 12px;
display: inline-block;
border-radius: 50%;
}
.nav-tabs {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.nav-tabs .nav-link {
border: none;
color: #6c757d;
padding: 0.5rem 1rem;
font-weight: 500;
}
.nav-tabs .nav-link.active {
color: #0d6efd;
border-bottom: 2px solid #0d6efd;
background: transparent;
}
.tab-content {
padding-top: 1rem;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 5px 0;
}
.detail-label {
font-weight: bold;
flex: 1;
text-align: left;
}
.detail-value {
flex: 1;
text-align: right;
font-weight: 600;
}
.dropdown {
position: relative;
}
.dropdown-menu.show {
display: block;
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
width: 100%;
max-height: 300px;
overflow-y: auto;
margin-top: 2px;
}
</style>