diff options
Diffstat (limited to '')
-rw-r--r-- | src/collectors/network-viewer.plugin/network-connections-chart.html | 706 |
1 files changed, 706 insertions, 0 deletions
diff --git a/src/collectors/network-viewer.plugin/network-connections-chart.html b/src/collectors/network-viewer.plugin/network-connections-chart.html new file mode 100644 index 000000000..4471b2add --- /dev/null +++ b/src/collectors/network-viewer.plugin/network-connections-chart.html @@ -0,0 +1,706 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Network Viewer</title> + <style> + /* Styles to make the canvas full width and height */ + body, html { + margin: 0; + padding: 0; + height: 100%; + width: 100%; + overflow: hidden; + } + + #d3-canvas { + height: 100%; + width: 100%; + } + </style> + <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400&display=swap" rel="stylesheet"> + <!-- Include D3.js --> + <script src="https://d3js.org/d3.v7.min.js"></script> +</head> +<body> +<div id="d3-canvas"></div> <!-- Div for D3 rendering --> + +<script> + + let config = { + listen: true, + } + + function transformData(dataPayload) { + // console.log("dataPayload", dataPayload); + const desiredColumns = ["Direction", "Protocol", "Namespace", "Process", "LocalIP", "LocalPort", "RemoteIP", "RemotePort", "LocalAddressSpace", "RemoteAddressSpace"]; + + const transformedData = []; + const appMap = new Map(); + + console.log(dataPayload); + dataPayload.data.forEach(row => { + const rowData = {}; + desiredColumns.forEach(columnName => { + const columnIndex = dataPayload.columns[columnName].index; + rowData[columnName] = row[columnIndex]; + }); + + const appName = rowData['Process']; + if (!appMap.has(appName)) { + appMap.set(appName, { + counts: { + listen: 0, + inbound: 0, + outbound: 0, + local: 0, + private: 0, + public: 0, + total: 0 + } + }); + } + + const appData = appMap.get(appName); + + if(config.listen || rowData['Direction'] !== 'listen') + appData.counts.total++; + + if (rowData['Direction'] === 'listen') + appData.counts.listen++; + else if (rowData['Direction'] === 'local') + appData.counts.local++; + else if (rowData['Direction'] === 'inbound') + appData.counts.inbound++; + else if (rowData['Direction'] === 'outbound') + appData.counts.outbound++; + + if (rowData['RemoteAddressSpace'] === 'public') + appData.counts.public++; + else if (rowData['RemoteAddressSpace'] === 'private') + appData.counts.private++; + }); + + // Convert the map to an array format + for (let [appName, appData] of appMap) { + transformedData.push({ + name: appName, + ...appData + }); + } + + if(!config.listen) + return transformedData.filter(function(d) { + return d.counts.total > 0; + }); + else + return transformedData; + } + + function normalizeData(data, w, h, borderPadding) { + const cw = w / 2 - borderPadding; + const ch = h / 2 - borderPadding; + + const minSize = 13; + const maxSize = Math.min( + (cw * 2) / 3 - borderPadding, + (ch * 2) / 3 - borderPadding, + Math.max(5, Math.min(w, h) / data.length) + minSize + ) + + const max = { + total: d3.max(data, d => d.counts.total), + local: d3.max(data, d => d.counts.local), + listen: d3.max(data, d => d.counts.listen), + private: d3.max(data, d => d.counts.private), + public: d3.max(data, d => d.counts.public), + inbound: d3.max(data, d => d.counts.inbound), + outbound: d3.max(data, d => d.counts.outbound), + } + + const circleSize = d3.scaleLog() + .domain([1, max.total]) + .range([minSize, maxSize]) + .clamp(true); // Clamps the output so that it stays within the range + + let listenerDelta = 50; + let listenersAdded = 0; + let listenersAddedRight = 0; + let listenersAddedLeft = 0; + let listenersH = h - borderPadding; + + data.forEach((d, i) => { + const logScaleRight = d3.scaleLog().domain([1, d.counts.public + 1]).range([0, cw]); + const logScaleLeft = d3.scaleLog().domain([1, d.counts.private + 1]).range([0, cw]); + const logScaleTop = d3.scaleLog().domain([1, d.counts.outbound + 1]).range([0, ch]); + const logScaleBottom = d3.scaleLog().domain([1, (d.counts.listen + d.counts.inbound) / 2 + 1]).range([0, ch]); + + d.forces = { + total: d.counts.total / max.total, + local: d.counts.local / max.local, + listen: d.counts.listen / max.listen, + private: d.counts.private / max.private, + public: d.counts.public / max.public, + inbound: d.counts.inbound / max.inbound, + outbound: d.counts.outbound / max.outbound, + } + + d.pos = { + // we add 1 to avoid log(0) + right: logScaleRight(d.counts.public + 1), + left: logScaleLeft(d.counts.private + 1), + top: logScaleTop(d.counts.outbound + 1), + bottom: logScaleBottom(((config.listen ? d.counts.listen : 0) + d.counts.inbound) / (config.listen ? 2 : 1) + 1), + }; + + let x = borderPadding + cw + d.pos.right - d.pos.left; + let y = borderPadding + ch + d.pos.bottom - d.pos.top; + let size = circleSize(d.counts.total); + + if(d.counts.listen === d.counts.total) { + // a listener + size = circleSize(1); + + if(listenersAdded * listenerDelta > cw * 2 / 3) { + // too many listeners on this row + // let's start adding on another row above + listenersAdded = 0; + listenersAddedLeft = 0; + listenersAddedRight = 0; + listenersH -= 30; + } + + if(!listenersAdded) { + x = cw; + y = listenersH - size; + } + else { + if(listenersAddedLeft >= listenersAddedRight) { + listenersAddedRight++; + x = cw + listenersAddedRight * listenerDelta; + y = listenersH - size - (listenersAddedRight % 2 === 0 ? 0 : 30); + } + else { + listenersAddedLeft++; + x = cw - listenersAddedLeft * listenerDelta; + y = listenersH - size - (listenersAddedLeft % 2 === 0 ? 0 : 30); + } + } + + listenersAdded++; + } + + const others = d.counts.total - (d.counts.public + d.counts.private + (config.listen ? d.counts.listen : 0) + d.counts.inbound + d.counts.outbound); + d.d3 = { + x: x, + y: y, + size: size, + pie: [ + { value: d.counts.public }, + { value: d.counts.private }, + { value: (config.listen ? d.counts.listen : 0) + d.counts.inbound }, + { value: d.counts.outbound }, + { value: others > 0 ? others : 0 }, + ] + } + + if(d.d3.x - d.d3.size / 2 < borderPadding) + d.d3.x = borderPadding + d.d3.size * 2; + + if(d.d3.x + d.d3.size / 2 > w) + d.d3.x = w - d.d3.size * 2; + + if(d.d3.y - d.d3.size / 2 < borderPadding) + d.d3.y = borderPadding + d.d3.size * 2; + + if(d.d3.y + d.d3.size / 2 > h) + d.d3.y = h - d.d3.size * 2; + + if (d.name === 'upsd') + console.log("object", d, "cw", cw, "ch", ch); + }); + + return data; + } + + const themes = { + dark: { + publicColor: "#bb9900", + privateColor: "#323299", + serverColor: "#008800", + clientColor: "#994433", + otherColor: "#454545", + backgroundColor: "black", + appFontColor: "#bbbbbb", + appFontFamily: 'IBM Plex Sans', + appFontSize: "12px", + appFontWeight: "regular", + borderFontColor: "#aaaaaa", + borderFontFamily: 'IBM Plex Sans', + borderFontSize: "14px", + borderFontWeight: "bold", + }, + light: { + publicColor: "#bbaa00", + privateColor: "#5555ff", + serverColor: "#009900", + clientColor: "#990000", + otherColor: "#666666", + backgroundColor: "white", + appFontColor: "black", + appFontFamily: 'IBM Plex Sans', + appFontSize: "12px", + appFontWeight: "bold", + borderFontColor: "white", + borderFontFamily: 'IBM Plex Sans', + borderFontSize: "14px", + borderFontWeight: "bold", + } + } + + function hexToHalfOpacityRGBA(hex) { + if (hex.length !== 7 || hex[0] !== '#') + throw new Error('Invalid hex color format'); + + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, 0.5)`; + } + + function getRgbColor(hex, opacity = 1) { + if (hex.length !== 7 || hex[0] !== "#") throw new Error("Invalid hex color format"); + + // Parse the hex color components (red, green, blue) + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + + // Ensure opacity is within the valid range (0 to 1) + const validOpacity = Math.min(1, Math.max(0, opacity)); + + // Return the RGBA color + return `rgba(${r}, ${g}, ${b}, ${validOpacity})`; + } + + function drawInitialChart(svg, data, w, h, borderPadding, theme) { + const cw = w / 2; + const ch = h / 2; + + document.body.style.backgroundColor = theme.backgroundColor; + + const clientsGradient = svg.append("defs") + .append("linearGradient") + .attr("id", "clientsGradient") + .attr("x1", "0%") + .attr("y1", "0%") + .attr("x2", "0%") + .attr("y2", "100%"); + + clientsGradient.append("stop") + .attr("offset", "0%") + .style("stop-color", getRgbColor(theme.clientColor, 1)); + + clientsGradient.append("stop") + .attr("offset", "100%") + .style("stop-color", getRgbColor(theme.clientColor, 0)); + + svg.append("rect") + .attr("id", "clientsGradientRect") + .attr("x", 0) + .attr("y", 0) + .attr("width", "100%") + .attr("height", borderPadding / 2) + .style("fill", "url(#clientsGradient)"); + + svg.append("text") + .attr("id", "clientsText") + .text("Clients") + .attr("x", "50%") + .attr("y", borderPadding / 2 - 4) + .attr("text-anchor", "middle") + .style("font-family", theme.borderFontFamily) + .style("font-size", theme.borderFontSize) + .style("font-weight", theme.borderFontWeight) + .style("fill", theme.borderFontColor); + + const serversGradient = svg.append("defs") + .append("linearGradient") + .attr("id", "serversGradient") + .attr("x1", "0%") + .attr("y1", "100%") // Start from the bottom + .attr("x2", "0%") + .attr("y2", "0%") // End at the top + + serversGradient.append("stop") + .attr("offset", "0%") + .style("stop-color", getRgbColor(theme.serverColor, 1)); + + serversGradient.append("stop") + .attr("offset", "100%") + .style("stop-color", getRgbColor(theme.serverColor, 0)); + + svg.append("rect") + .attr("id", "serversGradientRect") + .attr("x", 0) + .attr("y", h - borderPadding / 2) + .attr("width", "100%") + .attr("height", borderPadding / 2) + .style("fill", "url(#serversGradient)"); // Use the reversed gradient fill + + svg.append("text") + .attr("id", "serversText") + .text("Servers") + .attr("x", "50%") + .attr("y", h - borderPadding / 2 + 16) + .attr("text-anchor", "middle") + .style("font-family", theme.borderFontFamily) + .style("font-size", theme.borderFontSize) + .style("font-weight", theme.borderFontWeight) + .style("fill", theme.borderFontColor); + + const publicGradient = svg.append("defs") + .append("linearGradient") + .attr("id", "publicGradient") + .attr("x1", "100%") // Start from the right + .attr("y1", "0%") + .attr("x2", "0%") // End at the left + .attr("y2", "0%"); + + publicGradient.append("stop") + .attr("offset", "0%") + .style("stop-color", getRgbColor(theme.publicColor, 1)); + + publicGradient.append("stop") + .attr("offset", "100%") + .style("stop-color", getRgbColor(theme.publicColor, 0)); + + svg.append("rect") + .attr("id", "publicGradientRect") + .attr("x", w - borderPadding / 2) + .attr("y", 0) + .attr("width", borderPadding / 2) + .attr("height", "100%") + .style("fill", "url(#publicGradient)"); + + svg.append("text") + .attr("id", "publicText") + .text("Public") + .attr("x", w - (borderPadding / 2)) + .attr("y", ch - 10) + .attr("text-anchor", "middle") + .attr("dominant-baseline", "middle") + .attr("transform", `rotate(90, ${w - (borderPadding / 2)}, ${ch})`) + .style("font-family", theme.borderFontFamily) + .style("font-size", theme.borderFontSize) + .style("font-weight", theme.borderFontWeight) + .style("fill", theme.borderFontColor); + + const privateGradient = svg.append("defs") + .append("linearGradient") + .attr("id", "privateGradient") + .attr("x1", "0%") // Start from the left + .attr("y1", "0%") + .attr("x2", "100%") // End at the right + .attr("y2", "0%"); + + privateGradient.append("stop") + .attr("offset", "0%") + .style("stop-color", getRgbColor(theme.privateColor, 1)); + + privateGradient.append("stop") + .attr("offset", "100%") + .style("stop-color", getRgbColor(theme.privateColor, 0)); + + svg.append("rect") + .attr("id", "privateGradientRect") + .attr("x", 0) + .attr("y", 0) + .attr("width", borderPadding / 2) + .attr("height", "100%") + .style("fill", "url(#privateGradient)"); + + svg.append("text") + .attr("id", "privateText") + .text("Private") + .attr("x", borderPadding / 2) + .attr("y", ch) + .attr("text-anchor", "middle") + .attr("dominant-baseline", "middle") + .attr("transform", `rotate(-90, ${borderPadding / 2 - 10}, ${ch})`) + .style("font-family", theme.borderFontFamily) + .style("font-size", theme.borderFontSize) + .style("font-weight", theme.borderFontWeight) + .style("fill", theme.borderFontColor); + } + + function updateGradientBoxes(svg, w, h, borderPadding, theme) { + const cw = w / 2; + const ch = h / 2; + + // Update clientsGradient (top gradient) - mainly adjusting width + svg.select("rect#clientsGradientRect") + .attr("width", w); // Stretch across the new width + + // Update text position if necessary + svg.select("text#clientsText") + .attr("x", w / 2); // Center text + + // Update serversGradient (bottom gradient) - adjust Y position and width + svg.select("rect#serversGradientRect") + .attr("y", h - borderPadding / 2) // Move to the new bottom position + .attr("width", w) // Stretch across the new width + .attr("fill", "red"); + + // Update text position if necessary + svg.select("text#serversText") + .attr("x", w / 2) // Center text + .attr("y", h - borderPadding / 2 + 16); // Adjust based on your specific offset + + // Update publicGradient (right gradient) - adjust X position and height + svg.select("rect#publicGradientRect") + .attr("x", w - borderPadding / 2) // Move to the new right side position + .attr("height", h); // Stretch across the new height + + // Update text position if necessary, adjusting for rotation + svg.select("text#publicText") + .attr("x", w - (borderPadding / 2)) + .attr("y", ch - 10) + .attr("transform", `rotate(90, ${w - (borderPadding / 2)}, ${ch})`); + + // Update privateGradient (left gradient) - height adjustment only as it's already at x = 0 + svg.select("rect#privateGradientRect") + .attr("height", h); // Stretch across the new height + + // Update text position if necessary, adjusting for rotation + svg.select("text#privateText") + .attr("x", borderPadding / 2) + .attr("y", ch) + .attr("transform", `rotate(-90, ${borderPadding / 2 - 10}, ${ch})`); + + } + + let positionsMap = new Map(); + function saveCurrentPositions(svg) { + svg.selectAll('.app').each(function(d) { + if (d) { + positionsMap.set(d.name, { x: d.x, y: d.y }); + } + }); + } + + function updateApps(svg, data, w, h, borderPadding, theme) { + const cw = w / 2; + const ch = h / 2; + + saveCurrentPositions(svg); + //svg.selectAll('.app').remove(); + + const pieColors = d3.scaleOrdinal() + .domain(["public", "private", "listenInbound", "outbound", "others"]) + .range([theme.publicColor, theme.privateColor, theme.serverColor, theme.clientColor, theme.otherColor]); + + const pie = d3.pie().value(d => d.value); + const arc = d3.arc(); + + // Binding data with key function + const app = svg.selectAll('.app') + .data(data, d => d.name); + + // Remove any elements that no longer have data associated with them + app.exit() + .transition() + .style("opacity", 0) + .remove(); + + // Enter selection for new data points + const appEnter = app.enter() + .append('g') + .attr('class', 'app') + .attr('transform', `translate(${cw}, ${ch})`); // Start from center + + // Initialize new elements + appEnter.each(function (d) { + const group = d3.select(this); + const pieData = pie(d.d3.pie); + + group.selectAll('path') + .data(pieData) + .enter().append('path') + .transition() + .attr('fill', (d, i) => pieColors(i)); + + group.append('text') + .text(d => d.name) + .attr('text-anchor', 'middle') + .style('font-family', theme.appFontFamily) + .style('font-size', theme.appFontSize) + .style('font-weight', theme.appFontWeight) + .style('fill', theme.appFontColor); + }); + + // Transition for new elements + appEnter.transition() + .attr('transform', d => `translate(${d.d3.x}, ${d.d3.y})`); + + // Merge the enter and update selections + const mergedApp = appEnter.merge(app); + + // Update saved positions + mergedApp.each(function (d) { + const group = d3.select(this); + const oldPos = positionsMap.get(d.name) || { x: cw, y: ch }; + + d.x = oldPos.x; + d.y = oldPos.y; + + group.selectAll('path') + .data(pie(d.d3.pie)) + .transition() + .attr('d', arc.innerRadius(0).outerRadius(d.d3.size)); + + group.select('text') + .transition() + .attr('y', d.d3.size + 10); + }); + + // Apply the drag behavior to the merged selection + mergedApp.call(d3.drag() + .on('start', dragstarted) + .on('drag', dragged) + .on('end', dragended)); + + return mergedApp; + } + + let forceInit = true; + let initial = true; + let simulation; + + function dragstarted(event, d) { + if (!event.active) simulation.alphaTarget(1).restart(); + d.fx = d.x; + d.fy = d.y; + } + + function dragged(event, d) { + d.fx = event.x; + d.fy = event.y; + } + + function dragended(event, d) { + if (!event.active) simulation.alphaTarget(0); + d.fx = null; + d.fy = null; + } + + // Function to draw the circles and labels for each application with border forces + function drawApplications(data, w, h, borderPadding, theme) { + let svg = d3.select('#d3-canvas').select('svg'); + + if(svg.empty()) { + svg = d3.select('#d3-canvas').select('svg'); + svg = d3.select('#d3-canvas').append('svg') + .attr('width', '100%') + .attr('height', '100%'); + + drawInitialChart(svg, data, w, h, borderPadding, theme); + forceInit = false; + } + + updateGradientBoxes(svg, w, h, borderPadding, theme); + + const app = updateApps(svg, data, w, h, borderPadding, theme); + + app.transition().duration(5000) + + simulation = d3.forceSimulation(data) + //.force('center', d3.forceCenter(cw, ch).strength(1)) + .force("x", d3.forceX(d => d.d3.x).strength(d => { + if(d.counts.listen === d.counts.total) + return 0.5 + else + return 0.05 + })) + .force("y", d3.forceY(d => d.d3.y).strength(d => { + if(d.counts.listen === d.counts.total) + return 0.5 + else + return 0.05 + })) + //.force("charge", d3.forceManyBody().strength(-0.05)) + .force("collide", d3.forceCollide(d => d.d3.size * 1.1 + 15).strength(1)) + .on('tick', ticked); + + function ticked() { + data.forEach(d => { + if(d.x > w - d.d3.size) + d.x = w - d.d3.size; + else if(d.x < 0) + d.x = 0; + + if(d.y > h - d.d3.size) + d.y = h - d.d3.size; + else if(d.y < 0) + d.y = 0; + }); + + app.attr('transform', d => `translate(${d.x}, ${d.y})`); + } + + initial = false; + } + + let lastPayload = {}; + function redrawChart() { + const transformed = transformData(lastPayload); + console.log(transformed); + + const w = window.innerWidth; + const h = window.innerHeight; + const borderPadding = 40; + + const normalized = normalizeData(transformed, w, h, borderPadding); + drawApplications(normalized, w, h, borderPadding, themes.dark); + + // Update SVG dimensions + const svg = d3.select('#d3-canvas').select('svg') + .attr('width', w) + .attr('height', h); + } + + // Debounce function to optimize performance + function debounce(func, timeout = 50) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => { func.apply(this, args); }, timeout); + }; + } + + // Attach the event listener to the window resize event + window.addEventListener('resize', debounce(() => { + forceInit = true; + redrawChart(); + })); + + // Modify your fetchData function to call drawApplications after data transformation + function fetchDataAndUpdateChart() { + fetch('http://localhost:19999/api/v1/function?function=network-connections') + .then(response => response.json()) + .then(data => { + lastPayload = data; + redrawChart(); + }) + .catch(error => console.error('Error fetching data:', error)); + } + + // Initial load + window.onload = () => { + fetchDataAndUpdateChart(); + setInterval(fetchDataAndUpdateChart, 2000); // You may need to adjust this part + }; +</script> +</body> +</html> |