//
// Copyright © 2011-2025 Cambridge Intelligence Limited.
// All rights reserved.
//
// Sample Code
//
//! Help users identify and understand alerts in your networks.
import KeyLines from "keylines";
import { data, colours } from "./networkalerts-data.js";
let chart;
let graph;
// State tracking
let alwaysRevealedLinks = [];
const openCombos = {};
let visiblePorts = [];
// Map data values to strings for the Details panel
const osMap = {
win: "Windows",
mac: "macOS",
linux: "Linux",
phone: "Android",
};
const typeMap = {
linux: "Computer",
win: "Computer",
mac: "Computer",
printer: "Printer",
phone: "Smartphone",
server: "Server",
};
function getComboShape() {
return document.querySelector('input[name="shape"]:checked').value;
}
function getComboArrangement() {
const comboShape = getComboShape();
const isCircle = comboShape === "circle";
return isCircle ? "concentric" : "grid";
}
// Find all the links that have alerts attached.
function getAlertLinks() {
const alerts = [];
chart.each({ type: "link" }, (link) => {
if (link.d.alert) {
alerts.push(link.id);
}
});
return alerts;
}
function getComboList() {
const comboIds = [];
chart.each({ type: "node", items: "all" }, ({ id }) => {
if (chart.combo().isCombo(id)) {
comboIds.push(id);
}
});
return comboIds;
}
function decorateAlertLinks(linkIds) {
chart.setProperties(
linkIds.map((id) => ({
id,
c: colours.alertRed,
g: [
{
c: colours.alertRed,
b: colours.glyphBorder,
fi: { t: "fas fa-exclamation", c: colours.glyphFontColor },
e: 1.5,
},
],
}))
);
}
// Update the Details panel with details of a given item.
function updateUI(item) {
// hide any details that are already shown
document.querySelectorAll(".details").forEach((detail) => {
detail.classList.add("hide");
});
let root;
if (item) {
const { type } = item.d;
// For combo nodes, just display some very basic information
if (chart.combo().isCombo(item.id)) {
root = document.querySelector(".combo.details");
// display the type of item
root.querySelector(".field.combo-type label").textContent =
type || "Connection";
// and the label, if a node, or the labels of the nodes at either end if a link
const content =
item.type === "link"
? `${chart.getItem(item.id1).t[0].t} <--> ${
chart.getItem(item.id2).t[0].t
}`
: item.t[0].t;
root.querySelector(".field.detail .value").textContent = content;
} else {
// For regular nodes and links, display more detailed information (including alerts).
root = document.querySelector(`.${item.type}.details`);
if (item.type === "node") {
root.querySelector(".field.ip .value").textContent = item.t[0].t;
root.querySelector(".field.type .value").textContent = typeMap[type];
const os = item.d.type;
root.querySelector(".field.os .value").textContent = osMap[os] || "-";
root.querySelector(".field.host .value").textContent = item.d.network;
} else {
const src = chart.getItem(item.id1);
root.querySelector(
".field.from .value"
).textContent = `${src.t[0].t}:${item.d.port1}`;
const target = chart.getItem(item.id2);
root.querySelector(
".field.to .value"
).textContent = `${target.t[0].t}:${item.d.port2}`;
}
root.querySelector(".field.msg .value").innerHTML = `<li>${
item.d.alert || "None"
}</li>`;
}
root.classList.remove("hide");
} else {
document.querySelector(".about.details").classList.remove("hide");
}
}
// Update foreground state according to what is selected
// If a node: it's the node, its neighbours and the links between
// If a link: it's the link and the nodes at the ends of the link
function updateForeground(id) {
const toForeground = {};
const item = chart.getItem(id);
if (item) {
toForeground[id] = true;
if (item.type === "link") {
toForeground[item.id1] = true;
toForeground[item.id2] = true;
} else {
const neighbours = graph.neighbours(id);
neighbours.nodes.forEach((neighbourId) => {
toForeground[neighbourId] = true;
});
}
// Calculate foregrounding based on the nodes - the links will work themselves out.
chart.foreground((node) => toForeground[node.id]);
}
}
function getPortGlyph(link, port) {
const colour = link.d.alert ? colours.alertRed : colours.links;
return {
g: [
{
t: port,
c: colour,
fc: colours.glyphFontColor,
b: colour,
},
],
};
}
function showPorts(linkIds) {
// no link glyphs when using grid arrangements because nodes are too densely
// packed for the glyphs to be readable
const withGlyph = getComboShape() === "circle";
chart.setProperties(
linkIds.map((id) => {
const link = chart.getItem(id);
visiblePorts.push(link);
return {
id,
c: link.d.alert ? colours.alertRed : colours.links,
t1: withGlyph ? getPortGlyph(link, link.d.port1) : null,
t2: withGlyph ? getPortGlyph(link, link.d.port2) : null,
flow: link.d.flow,
};
})
);
const reveal = alwaysRevealedLinks.concat(linkIds);
chart.combo().reveal(reveal);
}
function hidePorts() {
chart.setProperties(
visiblePorts.map((link) => ({
id: link.id,
c: link.d.alert ? colours.alertRed : colours.links,
t1: null,
t2: null,
flow: false,
}))
);
visiblePorts = [];
chart.combo().reveal(alwaysRevealedLinks);
}
function select(itemId) {
hidePorts();
const item = chart.getItem(itemId);
if (item) {
updateUI(item);
if (chart.combo().isCombo(itemId)) {
chart.foreground(() => true);
} else {
if (item.type === "node") {
showPorts(graph.neighbours(itemId, { all: true }).links);
} else {
showPorts([itemId]);
}
updateForeground(itemId);
}
}
}
async function setComboShape() {
const shape = getComboShape();
chart.options({ combos: { shape } });
chart.setProperties({ id: ".", sh: shape === "circle" ? null : "box" }, true);
const selection = chart.selection();
if (selection) {
select(selection.pop());
}
// Arrange all combos from innermost to outmost to size the new shape appropriately and
// update the arrangement to take account of the new size of any nested combos
// get list of all combos
const comboIds = getComboList();
await chart.combo().close(comboIds);
await chart.combo().arrange(comboIds, { name: getComboArrangement() });
chart.layout("organic", { tightness: 6, mode: "adaptive" });
}
async function setSelectedLabels() {
const selections = chart.selection();
const props = [];
const colourLabel = (item, colour) => {
let labels = item.t;
if (Array.isArray(labels)) {
labels = labels.map((label) => ({ ...label, fbc: colour }));
} else if (typeof labels !== "string") {
labels = { ...labels, fbc: colour };
}
if (item.oc) {
item.oc = colourLabel(item.oc);
}
return { ...item, t: labels };
};
chart.each({ type: "node", items: "all" }, (e) => {
const { id, t, oc } = e;
const openComboProp = oc ? { oc } : {};
const colour = selections.includes(id) ? colours.selection : "transparent";
props.push({ id, ...colourLabel({ t, ...openComboProp }, colour) });
});
await chart.setProperties(props);
}
function initialiseInteractions() {
document
.getElementById("comboShape")
.addEventListener("change", setComboShape);
// Override default combo open behaviour to
// a) Track which combos are currently open
// b) Use concentric arrangement
chart.on("double-click", async ({ id: itemId, preventDefault }) => {
const item = chart.getItem(itemId);
if (item && item.type === "node" && chart.combo().isCombo(itemId)) {
if (chart.combo().isOpen(itemId)) {
delete openCombos[itemId];
await chart.combo().close(itemId);
// only run layout if top-level combo is closed
if (!item.parentId) {
await chart.layout("organic", {
mode: "adaptive",
tightness: 6,
consistent: true,
});
}
} else {
openCombos[itemId] = true;
chart.combo().open(itemId);
}
}
// disable default doubleclick behaviour
preventDefault();
});
// reveal, foreground and show details for the selected item
chart.on("selection-change", () => {
const selections = chart.selection();
setSelectedLabels();
if (selections.length) {
select(selections.pop());
} else {
hidePorts();
chart.foreground(() => true);
updateUI(null);
}
});
// disable marquee dragging
chart.on("drag-start", ({ type, preventDefault }) => {
if (type === "marquee") {
preventDefault();
}
});
}
function pingItem(ids) {
return chart.ping(ids, { c: colours.alertRed, time: 400 });
}
// returns an array of all the combos that contain a node, starting from the innermost
function ancestors(node) {
const result = [];
let nodeId = node.id;
while (nodeId) {
const parent = chart.combo().find(nodeId, { parent: "first" });
if (parent) {
result.push(parent);
nodeId = parent;
} else {
nodeId = null;
}
}
return result;
}
function zoom(ids) {
function firstZoomableParent(nodeId) {
// Get an array of all the node's ancestor combos, starting with the outermost.
const inwardsCombos = ancestors(chart.getItem(nodeId)).reverse();
// Find the first that is closed.
const outermostClosedCombo = inwardsCombos.find(
(ancestor) => !chart.combo().isOpen(ancestor)
);
// If our node has no closed ancestor combos, we can zoom to it directly.
return outermostClosedCombo || nodeId;
}
// We can't zoom to a node if it is within a closed combo.
// So instead we'll zoom to the outermost of its containing closed parent combos.
const zoomTargets = ids.map(firstZoomableParent);
return chart.zoom("fit", { animate: true, time: 800, ids: zoomTargets });
}
// Check whether one or more items is visible within the viewport.
function isVisible(itemIds) {
if (Array.isArray(itemIds)) {
return itemIds.every(isVisible);
}
const item = chart.getItem(itemIds);
const view = chart.viewOptions();
// get the view's bounds in world coordinates (minus an offset)
const topLeft = chart.worldCoordinates(20, 20);
const bottomRight = chart.worldCoordinates(view.width - 20, view.height - 20);
// Check whether the item is within that area
return (
item.x >= topLeft.x &&
item.x <= bottomRight.x &&
item.y >= topLeft.y &&
item.y <= bottomRight.y
);
}
// Reveal, zoom and highlight a particular item
// This 'unwraps' combos layer by layer to show the items inside
async function showAlert(itemId) {
const layers = []; // unwrap combos one layer at a time
const toOpen = {};
// Add any parenting combos for an item to the list of combos to unwrap
function addLayers(node) {
ancestors(node).forEach((ancestor, idx) => {
if (!layers[idx]) {
layers[idx] = [];
}
layers[idx].push(ancestor);
openCombos[ancestor] = true;
toOpen[ancestor] = true;
});
}
function arrange(comboids) {
// Open combos have already been arranged (prior to opening).
const closedComboIds = comboids.filter(
(comboId) => !chart.combo().isOpen(comboId)
);
return Promise.all(
closedComboIds.map((comboid) =>
chart
.combo()
.arrange(comboid, { animate: false, name: getComboArrangement() })
)
);
}
// the item ids that we will zoom to fit in the viewport
let zoomTo = [];
const item = chart.getItem(itemId);
async function done() {
await chart.layout("organic", {
mode: "adaptive",
tightness: 6,
consistent: true,
fit: false,
time: 400,
});
await zoom(zoomTo);
if (item.type === "link") {
return pingItem([item.id, item.id1, item.id2]);
}
return pingItem([item.id]);
}
// Unwrap one layer of combos (i.e. combos at the same depths)
async function unwrapLayer() {
updateForeground(itemId);
// Get the current topmost layer
const ids = layers.pop();
await arrange(ids);
await chart.combo().open(ids, { time: 800 });
if (layers.length) {
return unwrapLayer();
}
return done();
}
// for a link, we will unwrap and zoom to the nodes at either end
if (item.type === "link") {
addLayers(chart.getItem(item.id1));
addLayers(chart.getItem(item.id2));
zoomTo = [item.id1, item.id2];
// for a node, we just unwrap and zoom to the node itself
} else {
addLayers(item);
zoomTo = [itemId];
}
select(itemId);
// Close any combos which we don't need open and clear selection
const close = [];
chart.selection([]);
Object.keys(openCombos).forEach((id) => {
if (!toOpen[id]) {
delete openCombos[id];
close.push(id);
}
});
await chart.combo().close(close, { time: 800 });
if (close.length > 0) {
await chart.layout("organic", {
tightness: 6,
mode: "adaptive",
fit: false,
});
}
// Decide whether to zoom to the item before unwrapping the combos
if (!isVisible(zoomTo)) {
await zoom([itemId]);
}
return unwrapLayer();
}
// Populate the right-hand panel with alert information
function generateAlert(item) {
const alertsList = document.querySelector(".alerts");
const button = document.createElement("button");
button.innerHTML = item.d.alert;
button.setAttribute("value", item.d.alert);
button.addEventListener(
"click",
async () => {
const buttons = Array.from(alertsList.children);
// disable all the buttons during the animation
buttons.forEach((btn) => {
btn.disabled = true;
});
// run the animation
await showAlert(item.id);
// animation is now finished - enable the buttons again
buttons.forEach((btn) => {
btn.disabled = false;
});
},
false
);
const li = document.createElement("li");
li.appendChild(button);
alertsList.appendChild(button);
}
function populateAlertList() {
data.items.forEach((item) => {
if (item.d && item.d.alert) {
generateAlert(item);
}
});
}
async function startKeyLines() {
const options = {
drag: {
links: false,
},
logo: { u: "/images/Logo.png" },
selectionColour: colours.selection,
selectionFontColour: colours.selectionFontColor,
iconFontFamily: "Font Awesome 5 Free",
handMode: true,
imageAlignment: {
"fas fa-print": { e: 0.8 },
"fas fa-laptop": { e: 0.75 },
"fas fa-phone": { e: 0.8 },
"fas fa-server": { e: 0.8 },
"fas fa-sitemap": { e: 0.75, dy: -8 },
},
defaultStyles: {
comboGlyph: null,
},
gradient: {
stops: [
{ c: colours.backgroundLight, r: 0 },
{ c: colours.backgroundDark, r: 1.0 },
],
},
};
chart = await KeyLines.create({ container: "klchart", options });
populateAlertList();
initialiseInteractions();
// Use a graph engine to track relations in the underlying data
graph = KeyLines.getGraphEngine();
graph.load(data);
chart.load(data);
await setComboShape();
// reveal the links with alerts attached and make sure they stay that way
alwaysRevealedLinks = getAlertLinks();
chart.combo().reveal(alwaysRevealedLinks);
// also style them dramatically...
decorateAlertLinks(alwaysRevealedLinks);
chart.layout("organic", { tightness: 6, consistent: true });
updateStyling();
}
async function updateStyling() {
const getOuterLabels = (place, ip) => [
{
t: place,
position: "s",
fbc: colours.labelBackground,
borderRadius: 20,
bw: 0,
padding: "3 15 11 15",
minWidth: 50,
},
{
t: ip,
fbc: "rgba(0,0,0,0)",
fs: 8,
position: "s",
margin: { top: 18 },
},
];
const getInnerLabel = (ip) => ({
t: ip,
position: "s",
fbc: colours.labelBackground,
borderRadius: 20,
bw: 0,
padding: "3 5 1 5",
});
const props = [];
chart.each({ type: "node", items: "all" }, ({ id }) => {
const isCombo = chart.combo().isCombo(id);
const isOuter = chart.combo().find(id) === null;
const item = chart.getItem(id);
if (!isCombo) {
item.t[0].borderRadius = 20;
props.push({ id, t: item.t });
return;
}
if (isOuter) {
const placeName = item.t[0].t;
const ipAddr = item.t[1].t;
props.push({
id,
t: getOuterLabels(placeName, ipAddr),
oc: {
...item.oc,
t: getOuterLabels(placeName, ipAddr),
},
});
} else {
const ip = item.t[0].t;
props.push({
id,
t: getInnerLabel(ip),
oc: {
...item.oc,
t: getInnerLabel(ip),
},
});
}
});
await chart.setProperties(props);
}
function loadFontsAndStart() {
document.fonts.load('24px "Font Awesome 5 Free"').then(startKeyLines);
}
window.addEventListener("DOMContentLoaded", loadFontsAndStart);
<!DOCTYPE html>
<html lang="en" style="background-color: #2d383f;">
<head>
<meta charset="UTF-8">
<title>Network Alerts</title>
<link rel="stylesheet" type="text/css" href="/css/keylines.css">
<link rel="stylesheet" type="text/css" href="/css/minimalsdk.css">
<link rel="stylesheet" type="text/css" href="/css/sdk-layout.css">
<link rel="stylesheet" type="text/css" href="/css/demo.css">
<link rel="stylesheet" type="text/css" href="/css/fontawesome5/fontawesome.css">
<link rel="stylesheet" type="text/css" href="/css/fontawesome5/solid.css">
<link rel="stylesheet" type="text/css" href="/networkalerts.css">
<script src="/vendor/jquery.js" defer type="text/javascript"></script>
<script id="keylines" src="/public/keylines.umd.cjs" defer type="text/javascript"></script>
<script src="/networkalerts.js" crossorigin="use-credentials" defer type="module"></script>
</head>
<body>
<div class="chart-wrapper demo-cols">
<div class="tab-content-panel flex1" id="lhs" data-tab-group="rhs">
<div class="toggle-content is-visible" id="lhsVisual" style="width:100%; height: 100%;">
<div class="klchart" id="klchart">
</div>
</div>
</div>
<div class="rhs citext closed" id="demorhscontent">
<div class="title-bar">
<svg viewBox="0 0 360 60" style="max-width: 360px; max-height: 60px; font-size: 24px; font-family: Raleway; flex: 1;">
<text x="0" y="38" style="width: 100%;">Network Alerts</text>
</svg>
</div>
<div class="tab-content-panel" data-tab-group="rhs">
<div class="toggle-content is-visible tabcontent" id="controlsTab">
<form autocomplete="off" onsubmit="return false" id="rhsForm">
<div class="cicontent">
<p>
Double-click on a combo to see its contents, or select an alert from the list
below to reveal it in the chart.
</p>
</div>
<div class="cicontent">
<fieldset id="comboShape">
<legend>Combo shape</legend>
<label>
<input type="radio" name="shape" value="circle" checked><span>Circular</span>
</label>
<label class="radio inline">
<input type="radio" name="shape" value="rectangle"><span>Rectangular</span>
</label>
</fieldset>
<fieldset id="alerts">
<legend>Alerts found</legend>
</fieldset>
<ul class="alerts"></ul>
<fieldset>
<legend class="nav"><span>Details</span></legend>
</fieldset>
<div class="about details">
<p>Click on a node to display more details</p>
</div>
<div class="combo details hide">
<div class="field combo-type">
<label> </label>
</div>
<div class="field detail"><span class="value"></span></div>
</div>
<div class="node details hide">
<div class="field">
<label>Device</label>
</div>
<div class="field type">
<label>Type</label><span class="value"></span>
</div>
<div class="field ip">
<label>IP</label><span class="value"></span>
</div>
<div class="field host">
<label>Network</label><span class="value"></span>
</div>
<div class="field os">
<label>OS</label><span class="value"></span>
</div>
<div class="field msg">
<label>Alerts</label>
<ul class="value"></ul>
</div>
</div>
<div class="link details hide">
<div class="field">
<label>Connection</label>
</div>
<div class="field from">
<label>From</label><span class="value"></span>
</div>
<div class="field to">
<label>To</label><span class="value"></span>
</div>
<div class="field msg">
<label>Alerts</label>
<ul class="value"></ul>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="moreParent">
<div id="moreContainer">
</div>
</div>
<div id="lazyScripts">
</div>
</body>
</html>
.details label {
font-weight: bold;
min-width: 70px;
display: inline-block;
}
form label:first-child {
margin-top: 0px;
}
.details ul.value {
margin-left: 20px;
margin-top: 5px;
margin-bottom: 5px;
}
.alerts {
margin-left: 0;
padding: 0px;
}
.alerts button {
margin: 6px;
border: 1px solid transparent;
}
.field.combo-type label {
text-transform: capitalize;
}
Loading source
