mirror of
https://github.com/ntop/ntopng.git
synced 2026-05-03 09:20:10 +00:00
1577 lines
No EOL
64 KiB
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> |