This commit is contained in:
ryyst 2024-06-05 22:59:20 +03:00
parent 2048f9c57d
commit d90343b379
11 changed files with 785 additions and 416 deletions

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v22

View File

@ -2,19 +2,39 @@
## Development ## Development
### Backend
Pre-requisites: `pipenv`, or whichever venv manager you prefer
```sh ```sh
# Install all dependencies. `pipenv` was used previously: # Install all dependencies. `pipenv` was used previously:
pipenv --python 3.11 pipenv --python 3.11
pipenv install pipenv install
pipenv shell
# Start dev server # Start dev server
pipenv shell
fastapi dev app/main.py fastapi dev app/main.py
# Start posting data to the tracing endpoint: # Start posting data to the tracing endpoint:
traceroute git.rauhala.info -q1 | http POST localhost:8000/trace/MYHOSTNAME traceroute git.rauhala.info -q1 | http POST localhost:8000/trace/MYHOSTNAME
``` ```
### Frontend
Pre-requisites: `nvm`, `tsc` / `typescript`
```sh
# Install latest npm version with node version manager:
nvm install 22
# Install project packages:
npm install
# Regular usage, activate project node version & start TS compiler in watch mode:
nvm use
tsc -w
```
## URLs of interest ## URLs of interest
- http://localhost:8000/ - http://localhost:8000/

View File

@ -1,103 +0,0 @@
import json
import uuid
import hashlib
from datetime import datetime
from .db import Database
def parse_traceroute_output(data: str, origin: str):
# TODO: data validation
lines = data.strip().split("\n")
target = lines[0].split()[2]
created = datetime.now().isoformat()
trace = {"target": target, "created": created, "origin": origin, "hops": []}
prev_latency = None
for line in lines[1:]:
hop_info = line.split()
print("LINE:", hop_info)
try:
# Regular lines.
number, name, ip, latency, _ = hop_info
latency = float(latency)
hop = {
"created": created,
"number": number,
"name": name,
"ip": ip.strip("()"),
"latency": latency,
"link_latency": round(latency if prev_latency else latency, 3),
}
prev_latency = latency
except ValueError:
# Asterisks, no data found for hop.
number, name = hop_info
hop = {
"created": created,
"number": number,
"name": name,
"ip": None,
"latency": None,
"link_latency": "?",
}
trace["hops"].append(hop)
return trace
def store_traceroute(trace):
db = Database()
# hops_json = json.dumps(trace['hops'])
path_ids = {}
previous_hop_ip = None
previous_hop_latency = None
for hop in trace["hops"]:
hop_number = hop["number"]
hop_name = hop.get("name")
hop_ip = hop.get("ip")
hop_latency = hop.get("latency")
link_id = None
# insert links and get their id's
if previous_hop_ip:
link_id = db.create_link(previous_hop_ip, hop_ip)
path_ids[hop_number] = link_id
previous_hop_ip = hop_ip
# Save hop details
db.create_hop(hop_name, hop_ip, hop_latency)
# calculate link latency if possible and store it
if link_id and previous_hop_latency:
link_latency = hop_latency - previous_hop_latency
db.create_latency(link_id, trace["created"], link_latency)
# make entry to "Paths" table
if path_ids:
json_path_ids = json.dumps(path_ids)
db.create_path(node, trace["target"], json_path_ids)
db.end()
def generate_node_id():
mac = uuid.getnode()
mac_str = ":".join(
["{:02x}".format((mac >> ele) & 0xFF) for ele in range(0, 8 * 6, 8)][::-1]
)
# Hash the MAC address using SHA-256 to generate a unique ID
unique_id = hashlib.sha256(mac_str.encode()).hexdigest()
return unique_id

View File

@ -25,20 +25,24 @@ class Database:
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
created TEXT NOT NULL, created TEXT NOT NULL,
origin TEXT NOT NULL, origin TEXT NOT NULL,
target TEXT NOT NULL target TEXT NOT NULL,
unparsed TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS Hops ( CREATE TABLE IF NOT EXISTS Nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
trace_id INTEGER,
created TEXT NOT NULL, created TEXT NOT NULL,
number INTEGER NOT NULL,
name TEXT, name TEXT,
ip TEXT, ip TEXT,
latency TEXT, latency_ms REAL NOT NULL,
link_latency TEXT, );
FOREIGN KEY(trace_id) REFERENCES Traces(id) CREATE TABLE IF NOT EXISTS Links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created TEXT NOT NULL,
source TEXT NOT NULL,
target TEXT NOT NULL,
latency_ms REAL NOT NULL,
); );
""" """
) )
@ -49,6 +53,7 @@ class Database:
self.conn.commit() self.conn.commit()
self.conn.close() self.conn.close()
# TODO
def list_traces(self): def list_traces(self):
# TODO: time filter # TODO: time filter
result = [] result = []
@ -75,6 +80,7 @@ class Database:
return result return result
# TODO
def create_trace(self, trace): def create_trace(self, trace):
self.cursor.execute( self.cursor.execute(
"INSERT OR IGNORE INTO Traces (created, origin, target) VALUES (?, ?, ?)", "INSERT OR IGNORE INTO Traces (created, origin, target) VALUES (?, ?, ?)",
@ -87,7 +93,7 @@ class Database:
"INSERT OR IGNORE INTO Hops (trace_id, created, number, name, ip, latency, link_latency) VALUES (?, ?, ?, ?, ?, ?, ?)", "INSERT OR IGNORE INTO Hops (trace_id, created, number, name, ip, latency, link_latency) VALUES (?, ?, ?, ?, ?, ?, ?)",
( (
trace_id, trace_id,
hop["created"], hop["created"], # TODO: trace.created
hop["number"], hop["number"],
hop["name"], hop["name"],
hop["ip"], hop["ip"],
@ -109,79 +115,3 @@ def ensure_table_setup():
db = Database() db = Database()
db.create_tables() db.create_tables()
db.end() db.end()
####################################################################
####################################################################
####################################################################
####################################################################
def with_connection(func):
@wraps(func)
def wrapped(*args, **kwargs):
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
result = func(cursor, *args, **kwargs)
conn.commit()
conn.close()
return result
return wrapped
@with_connection
def init_db(cursor: Cursor):
cursor.executescript(
"""
CREATE TABLE IF NOT EXISTS Links (
id INTEGER PRIMARY KEY,
source_ip TEXT NOT NULL,
destination_ip TEXT NOT NULL,
UNIQUE(source_ip, destination_ip)
);
CREATE TABLE IF NOT EXISTS Paths (
id INTEGER PRIMARY KEY,
node TEXT NOT NULL,
target TEXT NOT NULL,
hops_json TEXT NOT NULL,
UNIQUE(node, target, hops_json)
);
CREATE TABLE IF NOT EXISTS Latency (
id INTEGER PRIMARY KEY,
link_id INTEGER NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
latency_ms REAL NOT NULL,
FOREIGN KEY (link_id) REFERENCES Links(id)
);
CREATE TABLE IF NOT EXISTS HopDetails (
id INTEGER PRIMARY KEY,
hop_name TEXT,
hop_ip TEXT,
hop_latency TEXT
);
"""
)
@with_connection
def insert_hop(cursor: Cursor, previous_hop_ip: str, hop_ip: str):
"""Insert a new hop and return related Link id"""
cursor.execute(
"INSERT OR IGNORE INTO Links (source_ip, destination_ip) VALUES (?, ?)",
(previous_hop_ip, hop_ip),
)
cursor.execute(
"SELECT id FROM Links WHERE source_ip = ? AND destination_ip = ?",
(previous_hop_ip, hop_ip),
)
return cursor.fetchone()

View File

@ -1,7 +1,7 @@
from fastapi import Request, FastAPI from fastapi import Request, FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from .collector import parse_traceroute_output, store_traceroute from .parser import parse_traceroute_output
from .db import Database, ensure_table_setup from .db import Database, ensure_table_setup
from pprint import pprint as print from pprint import pprint as print

119
app/parser.py Executable file
View File

@ -0,0 +1,119 @@
import json
import uuid
import hashlib
from datetime import datetime
from .db import Database
def parse_node(hop_info: list[str], target, origin):
try:
# Regular lines.
number, name, ip, latency, _ = hop_info
latency = float(latency)
ip = (ip.strip("()"),)
return {
"render_id": ip,
"hop_number": number,
"name": name,
"ip": ip,
"latency_ms": latency,
}
except ValueError:
# Asterisks, no data found for hop.
number, name = hop_info
return {
"render_id": f"{origin}-{target}-{number}",
"hop_number": number,
"name": name,
"ip": None,
"latency_ms": None,
}
def parse_link(node, prev_node):
latency = (
node["latency"] - prev_node["latency"]
if prev_node is not None
else node["latency"]
)
return {
"source": prev_node.get("render_id"),
"target": node["render_id"],
"latency_ms": latency,
}
def parse_traceroute_output(data: str, origin: str):
# TODO: data validation
lines = data.strip().split("\n")
target = lines[0].split()[2]
created = datetime.now().isoformat()
trace = {"target": target, "created": created, "origin": origin, "hops": []}
prev_node = None
for line in lines[1:]:
hop_info = line.split()
print("LINE:", hop_info)
node = parse_node(hop_info, target, origin)
link = parse_link(node, prev_node)
trace["nodes"].append(node)
trace["links"].append(link)
prev_node = node
return trace
# def store_traceroute(trace):
# # hops_json = json.dumps(trace['hops'])
#
# path_ids = {}
#
# previous_hop_ip = None
# previous_hop_latency = None
# for hop in trace["hops"]:
# hop_number = hop["number"]
# hop_name = hop.get("name")
# hop_ip = hop.get("ip")
# hop_latency = hop.get("latency")
# link_id = None
#
# # insert links and get their id's
# if previous_hop_ip:
# link_id = db.create_link(previous_hop_ip, hop_ip)
# path_ids[hop_number] = link_id
#
# previous_hop_ip = hop_ip
#
# # Save hop details
# db.create_hop(hop_name, hop_ip, hop_latency)
#
# # calculate link latency if possible and store it
# if link_id and previous_hop_latency:
# link_latency = hop_latency - previous_hop_latency
# db.create_latency(link_id, trace["created"], link_latency)
#
# # make entry to "Paths" table
# if path_ids:
# json_path_ids = json.dumps(path_ids)
# db.create_path(node, trace["target"], json_path_ids)
# def generate_node_id():
# mac = uuid.getnode()
# mac_str = ":".join(
# ["{:02x}".format((mac >> ele) & 0xFF) for ele in range(0, 8 * 6, 8)][::-1]
# )
#
# # Hash the MAC address using SHA-256 to generate a unique ID
# unique_id = hashlib.sha256(mac_str.encode()).hexdigest()
#
# return unique_id

184
app/static/example.js Normal file
View File

@ -0,0 +1,184 @@
/**
* This example showcases sigma's reducers, which aim to facilitate dynamically
* changing the appearance of nodes and edges, without actually changing the
* main graphology data.
*/
import Graph from "graphology";
import Sigma from "sigma";
import { Coordinates, EdgeDisplayData, NodeDisplayData } from "sigma/types";
import { onStoryDown } from "../utils";
import data from "./data.json";
export default () => {
// Retrieve some useful DOM elements:
const container = document.getElementById("sigma-container") as HTMLElement;
const searchInput = document.getElementById("search-input") as HTMLInputElement;
const searchSuggestions = document.getElementById("suggestions") as HTMLDataListElement;
// Instantiate sigma:
const graph = new Graph();
graph.import(data);
const renderer = new Sigma(graph, container);
// Type and declare internal state:
interface State {
hoveredNode?: string;
searchQuery: string;
// State derived from query:
selectedNode?: string;
suggestions?: Set<string>;
// State derived from hovered node:
hoveredNeighbors?: Set<string>;
}
const state: State = { searchQuery: "" };
// Feed the datalist autocomplete values:
searchSuggestions.innerHTML = graph
.nodes()
.map((node) => `<option value="${graph.getNodeAttribute(node, "label")}"></option>`)
.join("\n");
// Actions:
function setSearchQuery(query: string) {
state.searchQuery = query;
if (searchInput.value !== query) searchInput.value = query;
if (query) {
const lcQuery = query.toLowerCase();
const suggestions = graph
.nodes()
.map((n) => ({ id: n, label: graph.getNodeAttribute(n, "label") as string }))
.filter(({ label }) => label.toLowerCase().includes(lcQuery));
// If we have a single perfect match, them we remove the suggestions, and
// we consider the user has selected a node through the datalist
// autocomplete:
if (suggestions.length === 1 && suggestions[0].label === query) {
state.selectedNode = suggestions[0].id;
state.suggestions = undefined;
// Move the camera to center it on the selected node:
const nodePosition = renderer.getNodeDisplayData(state.selectedNode) as Coordinates;
renderer.getCamera().animate(nodePosition, {
duration: 500,
});
}
// Else, we display the suggestions list:
else {
state.selectedNode = undefined;
state.suggestions = new Set(suggestions.map(({ id }) => id));
}
}
// If the query is empty, then we reset the selectedNode / suggestions state:
else {
state.selectedNode = undefined;
state.suggestions = undefined;
}
// Refresh rendering
// You can directly call `renderer.refresh()`, but if you need performances
// you can provide some options to the refresh method.
// In this case, we don't touch the graph data so we can skip its reindexation
renderer.refresh({
skipIndexation: true,
});
}
function setHoveredNode(node?: string) {
if (node) {
state.hoveredNode = node;
state.hoveredNeighbors = new Set(graph.neighbors(node));
}
// Compute the partial that we need to re-render to optimize the refresh
const nodes = graph.filterNodes((n) => n !== state.hoveredNode && !state.hoveredNeighbors?.has(n));
const nodesIndex = new Set(nodes);
const edges = graph.filterEdges((e) => graph.extremities(e).some((n) => nodesIndex.has(n)));
if (!node) {
state.hoveredNode = undefined;
state.hoveredNeighbors = undefined;
}
// Refresh rendering
renderer.refresh({
partialGraph: {
nodes,
edges,
},
// We don't touch the graph data so we can skip its reindexation
skipIndexation: true,
});
}
// Bind search input interactions:
searchInput.addEventListener("input", () => {
setSearchQuery(searchInput.value || "");
});
searchInput.addEventListener("blur", () => {
setSearchQuery("");
});
// Bind graph interactions:
renderer.on("enterNode", ({ node }) => {
setHoveredNode(node);
});
renderer.on("leaveNode", () => {
setHoveredNode(undefined);
});
// Render nodes accordingly to the internal state:
// 1. If a node is selected, it is highlighted
// 2. If there is query, all non-matching nodes are greyed
// 3. If there is a hovered node, all non-neighbor nodes are greyed
renderer.setSetting("nodeReducer", (node, data) => {
const res: Partial<NodeDisplayData> = { ...data };
if (state.hoveredNeighbors && !state.hoveredNeighbors.has(node) && state.hoveredNode !== node) {
res.label = "";
res.color = "#f6f6f6";
}
if (state.selectedNode === node) {
res.highlighted = true;
} else if (state.suggestions) {
if (state.suggestions.has(node)) {
res.forceLabel = true;
} else {
res.label = "";
res.color = "#f6f6f6";
}
}
return res;
});
// Render edges accordingly to the internal state:
// 1. If a node is hovered, the edge is hidden if it is not connected to the
// node
// 2. If there is a query, the edge is only visible if it connects two
// suggestions
renderer.setSetting("edgeReducer", (edge, data) => {
const res: Partial<EdgeDisplayData> = { ...data };
if (state.hoveredNode && !graph.hasExtremity(edge, state.hoveredNode)) {
res.hidden = true;
}
if (
state.suggestions &&
(!state.suggestions.has(graph.source(edge)) || !state.suggestions.has(graph.target(edge)))
) {
res.hidden = true;
}
return res;
});
onStoryDown(() => {
renderer.kill();
});
};

View File

@ -4,9 +4,6 @@
<title>Kalzu</title> <title>Kalzu</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdnjs.cloudflare.com/ajax/libs/sigma.js/2.4.0/sigma.min.js" integrity="sha512-iiPEYww3QXZU5C795JnnINBRNgHqDnRHs9mA7aJoqx4pNE4u3CknCDGmePHFoHtKR/6C9aIcRFa+HJ6obtlteQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/graphology/0.25.4/graphology.umd.min.js" integrity="sha512-tjMBhL9fLMcqoccPOwpRiIQIOAyUh18lWUlUvE10zvG1UNMfxUC4qSERmUq+VF30iavIyqs/q6fSP2o475FAUw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="/static/d3.v7.min.js"></script>
<style> <style>
#container { #container {
height: 95vh; height: 95vh;

View File

@ -1,3 +1,13 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
const linkArc = (d) => { const linkArc = (d) => {
const r = Math.hypot(d.target.x - d.source.x, d.target.y - d.source.y) * 3; const r = Math.hypot(d.target.x - d.source.x, d.target.y - d.source.y) * 3;
return ` return `
@ -5,32 +15,29 @@ const linkArc = (d) => {
A${r},${r} 0 0,1 ${d.target.x},${d.target.y} A${r},${r} 0 0,1 ${d.target.x},${d.target.y}
`; `;
}; };
const drag = (simulation) => { const drag = (simulation) => {
function dragstarted(event, d) { function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart(); if (!event.active)
simulation.alphaTarget(0.3).restart();
d.fx = d.x; d.fx = d.x;
d.fy = d.y; d.fy = d.y;
} }
function dragged(event, d) { function dragged(event, d) {
d.fx = event.x; d.fx = event.x;
d.fy = event.y; d.fy = event.y;
} }
function dragended(event, d) { function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0); if (!event.active)
simulation.alphaTarget(0);
d.fx = null; d.fx = null;
d.fy = null; d.fy = null;
} }
return d3 return d3
.drag() .drag()
.on("start", dragstarted) .on("start", dragstarted)
.on("drag", dragged) .on("drag", dragged)
.on("end", dragended); .on("end", dragended);
}; };
// const drawChart = (data) => { // const drawChart = (data) => {
// // Specify the dimensions of the chart. // // Specify the dimensions of the chart.
// const width = 1600; // const width = 1600;
@ -129,7 +136,6 @@ const drag = (simulation) => {
// //
// return svg.node(); // return svg.node();
// }; // };
// const drawChart2 = (data) => { // const drawChart2 = (data) => {
// // Data parsing // // Data parsing
// const nodes = Array.from( // const nodes = Array.from(
@ -238,32 +244,22 @@ const drag = (simulation) => {
// scales: { color }, // scales: { color },
// }); // });
// }; // };
const state = {}; const state = {};
const drawSigma = (data) => { const drawSigma = (data) => {
// Create a graphology graph // Create a graphology graph
const graph = new graphology.MultiDirectedGraph(); const graph = new graphology.MultiDirectedGraph();
const setHoveredNode = (node) => { const setHoveredNode = (node) => {
if (node) { if (node) {
const props = graph.getNodeAttributes(node); const props = graph.getNodeAttributes(node);
state.hoveredNode = node; state.hoveredNode = node;
state.hoveredTrace = props.traceId; state.hoveredTrace = props.traceId;
// Compute the partial that we need to re-render to optimize the refresh // Compute the partial that we need to re-render to optimize the refresh
const nodes = graph.filterNodes((n) => { const nodes = graph.filterNodes((n) => {
const np = graph.getNodeAttributes(n); const np = graph.getNodeAttributes(n);
return np.traceId === state.hoveredTrace; return np.traceId === state.hoveredTrace;
}); });
console.log("Nämä?", nodes);
const nodesIndex = new Set(nodes); const nodesIndex = new Set(nodes);
const edges = graph.filterEdges((e) => const edges = graph.filterEdges((e) => graph.extremities(e).some((n) => nodesIndex.has(n)));
graph.extremities(e).some((n) => nodesIndex.has(n)),
);
// Refresh rendering // Refresh rendering
renderer.refresh({ renderer.refresh({
partialGraph: { partialGraph: {
@ -273,10 +269,10 @@ const drawSigma = (data) => {
// We don't touch the graph data so we can skip its reindexation // We don't touch the graph data so we can skip its reindexation
skipIndexation: true, skipIndexation: true,
}); });
} else { }
else {
state.hoveredNode = undefined; state.hoveredNode = undefined;
state.hoveredTrace = undefined; state.hoveredTrace = undefined;
// Refresh rendering // Refresh rendering
renderer.refresh({ renderer.refresh({
// We don't touch the graph data so we can skip its reindexation // We don't touch the graph data so we can skip its reindexation
@ -284,93 +280,77 @@ const drawSigma = (data) => {
}); });
} }
}; };
data.nodes.forEach((n) => { data.nodes.forEach((n) => {
try { try {
graph.addNode(n.id, n); graph.addNode(n.id, n);
} catch (e) { }
catch (e) {
// Duplicate node found, which is correct in our data scenario. // Duplicate node found, which is correct in our data scenario.
} }
}); });
data.links.forEach((l) => { data.links.forEach((l) => {
graph.addEdge(l.source, l.target, l); graph.addEdge(l.source, l.target, l);
}); });
// Instantiate sigma.js and render the graph // Instantiate sigma.js and render the graph
const renderer = new Sigma(graph, document.getElementById("container"), { const renderer = new Sigma(graph, document.getElementById("container"), {
// labelThreshold: -10000, // labelThreshold: -10000,
renderEdgeLabels: true, renderEdgeLabels: true,
}); });
// Bind graph interactions: // Bind graph interactions:
renderer.on("enterNode", ({ node }) => { renderer.on("enterNode", ({ node }) => {
console.log("enter"); console.log("enter:", node);
setHoveredNode(node); setHoveredNode(node);
}); });
renderer.on("leaveNode", () => { renderer.on("leaveNode", () => {
console.log("leave");
setHoveredNode(undefined); setHoveredNode(undefined);
}); });
// Render nodes accordingly to the internal state: // Render nodes accordingly to the internal state:
// 1. If a node is selected, it is highlighted // 1. If a node is selected, it is highlighted
// 2. If there is query, all non-matching nodes are greyed // 2. If there is query, all non-matching nodes are greyed
// 3. If there is a hovered node, all non-neighbor nodes are greyed // 3. If there is a hovered node, all non-neighbor nodes are greyed
renderer.setSetting("nodeReducer", (node, data) => { renderer.setSetting("nodeReducer", (node, data) => {
const res = { ...data }; const res = Object.assign({}, data);
const props = graph.getNodeAttributes(node); const props = graph.getNodeAttributes(node);
if (state.hoveredTrace && props.traceId !== state.hoveredTrace) { if (state.hoveredTrace && props.traceId !== state.hoveredTrace) {
res.label = ""; res.label = "";
res.color = "#f6f6f6"; res.color = "#f6f6f6";
} }
return res; return res;
}); });
// Render edges accordingly to the internal state: // Render edges accordingly to the internal state:
// 1. If a node is hovered, the edge is hidden if it is not connected to the // 1. If a node is hovered, the edge is hidden if it is not connected to the
// node // node
// 2. If there is a query, the edge is only visible if it connects two // 2. If there is a query, the edge is only visible if it connects two
// suggestions // suggestions
renderer.setSetting("edgeReducer", (edge, data) => { renderer.setSetting("edgeReducer", (edge, data) => {
const res = { ...data }; const res = Object.assign({}, data);
const props = graph.getEdgeAttributes(edge); const props = graph.getEdgeAttributes(edge);
if (state.hoveredTrace && props.traceId !== state.hoveredTrace) { if (state.hoveredTrace && props.traceId !== state.hoveredTrace) {
res.hidden = true; res.hidden = true;
} }
return res; return res;
}); });
}; };
const getNodeID = (hop, prevId, trace) => { const getNodeID = (hop, prevId, trace) => {
if (prevId === null) { if (prevId === null) {
return trace.origin; return trace.origin;
} }
if (hop.name === "*") { if (hop.name === "*") {
return `${trace.id}-${hop.number}-*`; return `${trace.target}-${hop.number}-*`;
//return `${trace.id}-${hop.number}-*`;
} }
return hop.ip; return hop.ip;
}; };
const parseNodesAndLinks = (traces) => { const parseNodesAndLinks = (traces) => {
const nodes = []; const nodes = [];
const links = []; const links = [];
const colors = Array.from(new Set(traces.map((t) => t.id))); const colors = Array.from(new Set(traces.map((t) => t.id)));
const color = d3.scaleOrdinal(colors, d3.schemeCategory10); const color = d3.scaleOrdinal(colors, d3.schemeCategory10);
traces.forEach((trace) => { traces.forEach((trace) => {
let prevId = null; let prevId = null;
let latestNumber = null; let latestNumber = null;
trace.hops.forEach((hop) => { trace.hops.forEach((hop) => {
const id = getNodeID(hop, prevId, trace); const id = getNodeID(hop, prevId, trace);
// New node // New node
nodes.push({ nodes.push({
id: id, id: id,
@ -382,7 +362,6 @@ const parseNodesAndLinks = (traces) => {
labelSize: 30, labelSize: 30,
color: color(trace.id), color: color(trace.id),
}); });
if (prevId) { if (prevId) {
// New link // New link
links.push({ links.push({
@ -395,11 +374,9 @@ const parseNodesAndLinks = (traces) => {
color: color(trace.id), color: color(trace.id),
}); });
} }
prevId = id; prevId = id;
latestNumber = hop.number; latestNumber = hop.number;
}); });
/** /**
* Last "destination" node, just for candy * Last "destination" node, just for candy
*/ */
@ -412,7 +389,6 @@ const parseNodesAndLinks = (traces) => {
size: 8, size: 8,
color: "black", color: "black",
}); });
links.push({ links.push({
label: "-", label: "-",
size: 8, size: 8,
@ -421,7 +397,6 @@ const parseNodesAndLinks = (traces) => {
target: trace.id, target: trace.id,
}); });
}); });
// { id: ip, group: origin, radius: 2 } // { id: ip, group: origin, radius: 2 }
// { source: prev.ip, target: ip, value: latency } // { source: prev.ip, target: ip, value: latency }
return { return {
@ -429,20 +404,16 @@ const parseNodesAndLinks = (traces) => {
links, links,
}; };
}; };
function main() {
async function main() { return __awaiter(this, void 0, void 0, function* () {
const response = await fetch("/trace/"); const response = yield fetch("/trace/");
const traces = await response.json(); const traces = yield response.json();
console.log("Traces:", traces); console.log("Traces:", traces);
const data = parseNodesAndLinks(traces); const data = parseNodesAndLinks(traces);
console.log("Data:", data); console.log("Data:", data);
// const chart = drawChart2(data.links); // const chart = drawChart2(data.links);
const chart = drawSigma(data); const chart = drawSigma(data);
// container.append(chart); // container.append(chart);
});
} }
main(); main();

240
app/static/index.ts Normal file
View File

@ -0,0 +1,240 @@
import { MultiDirectedGraph } from "graphology";
import Sigma from "sigma";
import { Coordinates, EdgeDisplayData, NodeDisplayData } from "sigma/types";
// Nice soft greens
const colors = ["#E8F9C2", "#C0DF81", "#96C832", "#59761E", "#263409"];
interface Node {
id: string;
label: string;
x: number;
y: number;
size: number;
color: string;
}
interface Link {
label: string;
size: number;
traceId: string;
source: string;
target: string;
}
interface Data {
nodes: Node[];
links: Link[];
}
interface State {
hoveredTrace: string | null;
hoveredNode: string | null;
}
const state: State = {
hoveredTrace: null,
hoveredNode: null,
};
const drawSigma = (data: Data) => {
// Create a graphology graph
const graph = new MultiDirectedGraph();
// Add nodes
data.nodes.forEach((n) => {
try {
graph.addNode(n.id, n);
} catch (e) {
// Duplicate node found, which is correct in our data scenario.
}
});
// Add links
data.links.forEach((l) => {
graph.addEdge(l.source, l.target, l);
});
const container = document.getElementById("container") as HTMLElement;
// Instantiate sigma.js and render the graph
const renderer = new Sigma(graph, container, {
// labelThreshold: -10000,
renderEdgeLabels: true,
});
// Bind graph interactions:
renderer.on("enterNode", ({ node }) => {
console.log("enter:", node);
if (!node) {
return;
}
const props = graph.getNodeAttributes(node);
state.hoveredNode = node;
state.hoveredTrace = props.traceId;
// Compute the partial that we need to re-render to optimize the refresh
const nodes = graph.filterNodes((n) => {
const np = graph.getNodeAttributes(n);
return np.traceId === state.hoveredTrace;
});
const nodesIndex = new Set(nodes);
const edges = graph.filterEdges((e) =>
graph.extremities(e).some((n) => nodesIndex.has(n)),
);
// Refresh rendering
renderer.refresh({
partialGraph: {
nodes,
edges,
},
// We don't touch the graph data so we can skip its reindexation
skipIndexation: true,
});
});
renderer.on("leaveNode", () => {
state.hoveredNode = null;
state.hoveredTrace = null;
// Refresh rendering
renderer.refresh({
// We don't touch the graph data so we can skip its reindexation
skipIndexation: true,
});
});
// Render nodes accordingly to the internal state:
// 1. If a node is selected, it is highlighted
// 2. If there is query, all non-matching nodes are greyed
// 3. If there is a hovered node, all non-neighbor nodes are greyed
renderer.setSetting("nodeReducer", (node, data) => {
const res = { ...data };
const props = graph.getNodeAttributes(node);
if (state.hoveredTrace && props.traceId !== state.hoveredTrace) {
res.label = "";
res.color = "#f6f6f6";
}
return res;
});
// Render edges accordingly to the internal state:
// 1. If a node is hovered, the edge is hidden if it is not connected to the
// node
// 2. If there is a query, the edge is only visible if it connects two
// suggestions
renderer.setSetting("edgeReducer", (edge, data) => {
const res = { ...data };
const props = graph.getEdgeAttributes(edge);
if (state.hoveredTrace && props.traceId !== state.hoveredTrace) {
res.hidden = true;
}
return res;
});
};
// const getNodeID = (hop: number, prevId: string, trace) => {
// if (prevId === null) {
// return trace.origin;
// }
// if (hop.name === "*") {
// return `${trace.target}-${hop.number}-*`;
// //return `${trace.id}-${hop.number}-*`;
// }
//
// return hop.ip;
// };
// const parseNodesAndLinks = (traces) => {
// const nodes = [];
// const links = [];
//
// const colors = Array.from(new Set(traces.map((t) => t.id)));
// // TODO: Replace this with something
// const color = d3.scaleOrdinal(colors, d3.schemeCategory10);
//
// traces.forEach((trace) => {
// let prevId = null;
// let latestNumber = null;
//
// trace.hops.forEach((hop) => {
// const id = getNodeID(hop, prevId, trace);
//
// // New node
// nodes.push({
// id: id,
// label: id.endsWith("*") ? "*" : id,
// x: trace.id,
// y: hop.number / 2,
// traceId: trace.id,
// size: 9,
// labelSize: 30,
// color: color(trace.id),
// });
//
// if (prevId) {
// // New link
// links.push({
// label: hop.link_latency,
// source: prevId,
// target: id,
// traceId: trace.id,
// origin: trace.origin,
// size: 3,
// color: color(trace.id),
// });
// }
//
// prevId = id;
// latestNumber = hop.number;
// });
//
// /**
// * Last "destination" node, just for candy
// */
// nodes.push({
// id: trace.id,
// label: trace.target,
// traceId: trace.id,
// x: trace.id,
// y: (latestNumber + 1) / 2,
// size: 8,
// color: "black",
// });
//
// links.push({
// label: "-",
// size: 8,
// traceId: trace.id,
// source: prevId,
// target: trace.id,
// });
// });
//
// // { id: ip, group: origin, radius: 2 }
// // { source: prev.ip, target: ip, value: latency }
// return {
// nodes,
// links,
// };
// };
async function main() {
const response = await fetch("/trace/");
const data = await response.json();
console.log("Traces:", data);
console.log("Data:", data);
drawSigma(data);
}
main();

10
tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}