Appearance
Cyber security log analysis
This example shows how to use NodeFilters and EdgeFilters to cleanup the graph and focus on a certain period of time, or on nodes with a certain property.
Note that the graph is not modified, only the view is filtered.
The timeline shown here is very simple, but we provide a much more complete timeline plugin here: ogma-timeline
ts
import Ogma, {
NodeGrouping,
EdgeFilter,
NodeFilter,
EdgeGrouping
} from '@linkurious/ogma';
import './style.css';
import { recordsToGraph } from './parse';
import { addStyles } from './styles';
import { CONNECTS_TO, CSVRow, ED, ND, PORT } from './types';
import { setupTimeline } from './timeline';
const ogma = new Ogma({
container: 'graph-container',
options: {
backgroundColor: null,
detect: { nodeTexts: false, edgeTexts: false }
}
});
// node types
const ANIMATION_DURATION = 0;
type AppState = {
edgeGrouping: boolean;
selectedPorts: { [key: string]: boolean };
ports?: { [key: string]: string[] };
};
const appState: AppState = {
edgeGrouping: true,
selectedPorts: {}
};
type Transformations = {
edgeGrouping?: EdgeGrouping<ED, ND>;
portFilter?: NodeFilter<ND, ED>;
};
// transformations
const transformations: Transformations = {
edgeGrouping: undefined,
portFilter: undefined
};
const initTransformations = () => {
// port filtering policy
transformations.portFilter = ogma.transformations.addNodeFilter({
criteria: node => {
if (node.getData('type') === PORT) {
return appState.selectedPorts[node.getData('port')];
} else {
return true;
}
},
enabled: true
});
// group all edges between an “IP” and a “PORT” node by (protocol + service)
// Grouped edge data:
// - protocol
// - service
// - total query bytes
// - total response bytes
// - group size (number of edges in the group)
transformations.edgeGrouping = ogma.transformations.addEdgeGrouping({
selector: edge =>
!edge.isExcluded() && edge.getData('type') === CONNECTS_TO,
groupIdFunction: e => e.getData('protocol') + '/' + e.getData('service'),
generator: edges => {
const e = edges.get(0);
const protocol = e.getData('protocol');
const service = e.getData('service');
let query_bytes = 0,
response_bytes = 0;
edges.getData().forEach(edgeData => {
query_bytes += edgeData.query_bytes || 0;
response_bytes += edgeData.response_bytes || 0;
});
return {
data: {
type: CONNECTS_TO,
query_bytes: query_bytes,
response_bytes: response_bytes,
group_size: edges.size,
protocol: protocol,
service: service
}
};
},
enabled: false
});
};
const layout = () =>
ogma.layouts.force({
gpu: true,
charge: 7,
locate: true
});
document.querySelector('#menu-toggle')!.addEventListener('click', () => {
document.querySelector('.panel')!.classList.toggle('closed');
});
document
.querySelector('#dark-mode-toggle')!
.addEventListener('sl-change', () => {
document.documentElement.classList.toggle('sl-theme-dark');
});
const ports = [
...document.querySelectorAll('sl-checkbox[data-port]')
] as HTMLInputElement[];
const groupToggle = document.getElementById(
'grouping-toggle'
) as HTMLInputElement;
ports.forEach(portInput => {
const port = portInput.getAttribute('data-port')!;
appState.selectedPorts[port] = true;
portInput.addEventListener('sl-change', evt => {
appState.selectedPorts[port] = (evt.target as HTMLInputElement).checked;
updateState();
});
});
groupToggle.addEventListener('sl-change', () => {
appState.edgeGrouping = groupToggle.checked;
updateState();
});
const updateState = () => {
// time filter
if (appState.edgeGrouping !== transformations.edgeGrouping?.isEnabled()) {
transformations.edgeGrouping?.toggle(ANIMATION_DURATION);
}
transformations.portFilter?.refresh();
return ogma.transformations.afterNextUpdate();
};
// load data
const loadAndParseCSV = (url: string) =>
new Promise((complete, error) => {
// @ts-ignore
Papa.parse(url, {
download: true,
complete: complete,
error: error
});
}) as Promise<{ data: CSVRow[] }>;
loadAndParseCSV('./logs-merged_sample.csv')
.then(records => recordsToGraph(records.data))
.then(graph => ogma.setGraph(graph))
.then(() => layout())
// .then(graph => initUI(graph))
.then(() => setupTimeline(ogma))
.then(initTransformations)
.then(() => updateState())
.then(() => addStyles(ogma))
.then(() => ogma.view.locateGraph({ padding: 20 }));
html
<!doctype html>
<html>
<head>
<title>Ogma cyber security analysis</title>
<link
href="https://cdn.jsdelivr.net/npm/lucide-static@0.483.0/font/lucide.css"
rel="stylesheet"
/>
<link type="text/css" rel="stylesheet" href="style.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.3.0/papaparse.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/crossfilter2/1.4.6/crossfilter.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<link
href="https://cdn.jsdelivr.net/npm/vis-timeline@latest/styles/vis-timeline-graph2d.min.css"
rel="stylesheet"
type="text/css"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@linkurious/ogma-timeline-plugin@latest/dist/style.css"
/>
<script src="https://cdn.jsdelivr.net/npm/@linkurious/ogma-ui-kit@0.0.9/dist/ogma-ui-kit.min.js"></script>
</head>
<body>
<div id="graph-container"></div>
<div class="panel">
<div id="title">
<sl-icon name="shield-user" library="lucide"> </sl-icon>
<span class="hide-closed">Cyber Security Company</span>
<sl-icon
name="chevron-left"
library="lucide"
id="menu-toggle"
title="Hide Menu"
></sl-icon>
</div>
<div id="legend">
<div class="title">KEY</div>
<ul>
<li>
<span class="legend ip"></span>
<span>IP Address</span>
</li>
<li>
<span class="legend port"> </span>
<span>Port</span>
</li>
<li>
<span class="legend">
<svg
width="12"
height="12"
viewBox="-1 -1 14 14"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Centered circle -->
<circle cx="6" cy="6" r="6" stroke="#DE425B" fill="none" />
<!-- Centered square -->
<rect x="3" y="3" width="6" height="6" fill="#5bdbe2" />
</svg>
</span>
<span>Compromised Port</span>
</li>
<li>
<sl-icon name="move-right" library="lucide"> </sl-icon>
<span>Listens to</span>
</li>
</ul>
</div>
<sl-divider></sl-divider>
<div id="ports">
<div class="title">PORTS</div>
<form>
<sl-checkbox id="port-0" data-port="0" checked>0</sl-checkbox>
<sl-checkbox id="port-443" data-port="443" checked>443</sl-checkbox>
<sl-checkbox id="port-3" data-port="3" checked>3</sl-checkbox>
<sl-checkbox id="port-1900" data-port="1900" checked
>1900</sl-checkbox
>
<sl-checkbox id="port-53" data-port="53" checked>53</sl-checkbox>
<sl-checkbox id="port-8080" data-port="8080" checked
>8080</sl-checkbox
>
<sl-checkbox id="port-67" data-port="67" checked>67</sl-checkbox>
<sl-checkbox id="port-16464" data-port="16464" checked
>16464</sl-checkbox
>
<sl-checkbox id="port-80" data-port="80" checked>80</sl-checkbox>
<sl-checkbox id="port-16471" data-port="16471" checked
>16471</sl-checkbox
>
<sl-checkbox id="port-123" data-port="123" checked>123</sl-checkbox>
<sl-checkbox id="port-62243" data-port="62243" checked
>62243</sl-checkbox
>
</form>
</div>
<sl-divider></sl-divider>
<div id="connections">
<div class="title">CONNECTIONS</div>
<div>
<span>Grouping</span>
<sl-switch id="grouping-toggle" checked></sl-switch>
</div>
</div>
<sl-divider></sl-divider>
<div id="theme">
<div class="title">THEME</div>
<div>
<span>Dark Mode</span>
<sl-switch id="dark-mode-toggle"></sl-switch>
</div>
</div>
</div>
<div id="timeline"></div>
<script src="index.ts"></script>
</body>
</html>
css
:root {
--base-color: #4999f7;
--active-color: var(--base-color);
--gray: #d9d9d9;
--dark-color: #3a3535;
--timeline-bar-fill: #166397;
--timeline-bar-selected-fill: #ff9685;
--timeline-bar-selected-stroke: #ff6c52;
--timeline-bar-filtered-fill: #99d6ff;
}
body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
display: grid;
grid-template-columns: 12px 216px auto;
grid-template-rows: 8px auto 20%;
font-size: 12px;
max-height: 100vh;
overflow: hidden;
}
#graph-container {
grid-area: 2 / 3 / 3 / 4;
}
#timeline {
grid-area: 3 / 3 / 3 / 4;
}
#legend > ul {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: 12px auto;
grid-template-rows: repeat(12px, 4);
align-items: center;
gap: 8px;
}
#legend > ul > li {
display: contents;
}
#ip-address {
display: grid;
grid-template-columns: 73px auto;
justify-content: space-between;
}
.splitter {
border-top: 1px solid #e9e9e9;
margin: 12px -16px 8px -16px;
}
form {
display: grid;
grid-template-columns: repeat(2, auto); /* Four columns */
justify-items: start;
align-items: center;
gap: 8px 0px;
margin-right: 4px;
}
#title {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
margin-bottom: 8px;
}
#title sl-icon {
width: 22px;
height: 22px;
background: #0d003a;
color: white;
border-radius: 5px;
padding: 2px;
cursor: pointer;
}
.title {
margin-bottom: 8px;
}
.panel sl-divider {
margin-top: 8px;
margin-bottom: 8px;
}
sl-checkbox {
height: 14px;
}
sl-icon#menu-toggle {
background-color: white;
color: black;
width: 24px;
height: 24px;
border-radius: 50%;
position: absolute;
left: 212px;
top: 16px;
box-shadow: 0px 1px 4px 0px #00000026;
}
#connections > div,
#theme > div {
display: flex;
flex-direction: row;
justify-content: space-between;
}
#legend sl-icon {
width: 12px;
height: 12px;
}
.legend.ip {
width: 9px;
height: 9px;
border-radius: 50%;
background-color: #0094ff;
}
.legend.port {
width: 12px;
height: 12px;
background-color: #5bdbe2;
}
.vis-group.vis-bar {
fill: var(--timeline-bar-fill);
stroke: var(--timeline-bar-fill);
}
.vis-group.vis-bar.vis-selected {
fill: var(--timeline-bar-selected-fill);
stroke: var(--timeline-bar-selected-stroke);
}
.vis-group.vis-bar.vis-filtered {
fill: var(--timeline-bar-filtered-fill);
stroke: var(--timeline-bar-filtered-fill);
opacity: 1;
}
.sl-theme-dark .vis-grid.vis-major {
border-color: white;
}
.sl-theme-dark .vis-text {
color: white;
}
/**
Transitions
*/
body {
transition: grid-template-columns 0.2s ease;
}
/* Quand le panneau est fermé */
body:has(.panel.closed) {
grid-template-columns: 0 0 auto;
}
.panel {
gap: 0;
transition: width 0.2s ease;
}
.panel.closed {
padding: 8px;
width: 58px; /* Enough for the icons */
}
.panel.closed #title {
margin: 0;
}
/* Hide everything inside the panel *except* the title */
.panel.closed > *:not(#title),
.panel.closed > .hide-closed {
display: none;
}
.panel.closed .hide-closed {
display: none;
}
#menu-toggle {
transition: transform 0.2s ease;
}
.panel.closed #menu-toggle {
transform: translateX(-168px) translateY(-8px) rotate(180deg);
transition: transform 0.2s ease;
}
/* Keep title layout nice in collapsed state */
.panel #title {
display: flex;
align-items: center;
gap: 8px;
}
ts
import { RawEdge, RawNode } from '@linkurious/ogma';
import {
SERVER,
PORT,
IP,
LISTENS_TO,
CONNECTS_TO,
ServerData,
PortData,
ListenData,
IPData,
ConnectData,
CSVRow
} from './types';
export function recordsToGraph(records: CSVRow[]) {
// deduplication lookup hashes
const portsMap: Record<string, RawNode<PortData>> = {};
const ipsMap: Record<string, RawNode<IPData>> = {};
const serversMap: Record<string, RawNode<ServerData>> = {};
const listensMap: Record<string, RawEdge<ListenData>> = {};
const nodes: RawNode[] = [],
edges: RawEdge[] = [];
records.forEach(record => {
const [
timestamp,
src_ip,
dest_ip,
dest_port,
protocol,
service,
duration,
out_bytes,
in_bytes,
status
] = record;
// Port node
const portId = dest_ip + ':' + dest_port;
let port = portsMap[portId];
if (!port) {
port = portsMap[portId] = {
id: portId,
data: {
ip: dest_ip,
port: dest_port,
type: PORT
}
};
nodes.push(port);
}
// Server node
const serverId = dest_ip;
let server = serversMap[serverId];
if (!server) {
server = serversMap[serverId] = {
id: serverId,
data: {
ip: dest_ip,
type: SERVER
}
};
nodes.push(server);
}
// "Listens to" edge
let listen = listensMap[portId];
if (!listen) {
listen = listensMap[portId] = {
id: portId,
source: serverId,
target: portId,
data: {
type: LISTENS_TO
}
};
edges.push(listen);
}
// IP node
const ipId = src_ip;
let ip = ipsMap[ipId];
if (!ip) {
ip = ipsMap[ipId] = {
id: ipId,
data: {
ip: src_ip,
type: IP
}
};
nodes.push(ip);
}
// Connection edge
const connectionId = `${src_ip}-${dest_ip}:${dest_port}@${Math.round(
timestamp * 1000
)}`;
const start = Number(new Date('Fri May 16 2025 15:49:22'));
const connection: RawEdge<ConnectData> = {
id: connectionId,
source: ipId,
target: portId,
data: {
type: CONNECTS_TO,
start: Math.round(timestamp) + start,
query_bytes: isNaN(out_bytes as number)
? 0
: parseInt(out_bytes as string),
response_bytes: isNaN(in_bytes as number)
? 0
: parseInt(in_bytes as string),
protocol: protocol,
service: service
}
};
edges.push(connection);
});
return { nodes: nodes, edges: edges };
}
ts
import Ogma from '@linkurious/ogma';
import { CONNECTS_TO, IP, LISTENS_TO, PORT, SERVER } from './types';
import { getIconCode, formatPutThrough } from './utils';
// Style the graph based on the properties of nodes and edges
export function addStyles(ogma: Ogma) {
ogma.styles.addNodeRule(
node => {
return node.getData('type') === SERVER;
},
{
radius: 10,
color: '#fff',
icon: {
font: 'Lucide',
content: getIconCode('icon-server'),
color: '#0094ff',
style: 'bold',
minVisibleSize: 0,
scale: 0.7
}
}
);
// node styles
ogma.styles.addNodeRule(
n => {
return n.getData('type') === IP;
},
{
innerStroke: '#0094ff',
color: '#fff',
// number of outgoing “CONNECTS_TO” edges (taking filters into account)
radius: node =>
10 +
Math.min(
node.getAdjacentEdges({ direction: 'out' }).filter(edge => {
return edge.getData('type') === CONNECTS_TO;
}).size,
40
),
icon: {
font: 'Lucide',
content: getIconCode('icon-server'),
style: 'bold',
color: '#0094ff',
minVisibleSize: 0,
scale: 0.4
},
text: {
content: node => node.getData('ip'),
secondary: {
content: (
node // number of outgoing “CONNECTS_TO” edges (taking filters into account)
) =>
node.getAdjacentEdges({ direction: 'out' }).filter(edge => {
return edge.getData('type') === CONNECTS_TO;
}).size +
' connections\n' +
// received bytes (sum of all “response_bytes” from outgoing
// “CONNECTS_TO” edges, taking filters into account)
formatPutThrough(
node
.getAdjacentEdges({ direction: 'out' })
.filter(edge => {
return edge.getData('type') === CONNECTS_TO;
})
.reduce((total, edge) => {
return total + edge.getData('response_bytes');
}, 0)
) +
' received\n' +
// sent bytes (sum of all “query_bytes” from outgoing “CONNECTS_TO” edges,
// taking filters into account)
formatPutThrough(
node
.getAdjacentEdges({ direction: 'out' })
.filter(edge => {
return edge.getData('type') === CONNECTS_TO;
})
.reduce((total, edge) => {
return total + edge.getData('query_bytes');
}, 0)
) +
' sent\n',
color: '#0094ff',
backgroundColor: 'white',
minVisibleSize: 60,
margin: 1
},
color: 'white',
backgroundColor: '#0094ff'
}
}
);
ogma.styles.addNodeRule(
n => {
return n.getData('type') === PORT;
},
{
color: '#5BDBE2',
shape: 'square',
text: {
content: node => ':' + node.getData('port'),
position: 'center'
},
radius: 8,
// Highlight Port 1900 as dangerous
pulse: {
enabled: node => node.getData('port') === '1900',
endRatio: 5,
width: 1,
startColor: 'red',
endColor: 'red',
interval: 1000,
startRatio: 1.0
}
}
);
ogma.styles.addEdgeRule({
color: edge =>
edge.getData('type') === CONNECTS_TO ? '#0094ff' : 'orange',
width: edge => 1 + 1.3 * Math.sqrt(edge.getData('group_size')),
shape: edge => (edge.getData('type') === CONNECTS_TO ? undefined : 'arrow'),
text: {
content: edge => {
if (edge.getData('type') === CONNECTS_TO) {
const response_bytes = edge.getData('response_bytes') || 0;
const query_bytes = edge.getData('query_bytes') || 0;
return (
formatPutThrough(query_bytes) +
' in, ' +
formatPutThrough(response_bytes) +
' out'
);
} else {
return LISTENS_TO;
}
},
color: edge =>
edge.getData('type') === CONNECTS_TO ? '#0094ff' : 'orange'
}
});
ogma.styles.setHoveredNodeAttributes({
outerStroke: {
width: 2,
color: '#ff6c52',
scalingMethod: 'fixed'
},
text: {
backgroundColor: '0094ff'
}
});
ogma.styles.setSelectedNodeAttributes({
outerStroke: {
width: 2,
color: '#ff6c52',
scalingMethod: 'fixed'
},
text: {
style: 'bold',
backgroundColor: '0094ff'
}
});
ogma.styles.setHoveredEdgeAttributes({
color: '#ff6c52',
text: {
backgroundColor: 'white'
}
});
ogma.styles.setSelectedEdgeAttributes({
color: '#ff6c52',
text: {
style: 'bold',
backgroundColor: 'white'
}
});
}
ts
import { Controller as TimelinePlugin } from '@linkurious/ogma-timeline-plugin';
import Ogma, { NodeList, EdgeList, EdgeId } from '@linkurious/ogma';
import { CONNECTS_TO } from './types';
export function setupTimeline(ogma: Ogma) {
const container = document.getElementById('timeline');
const timelinePlugin = new TimelinePlugin(ogma, container, {
barchart: {
graph2dOptions: {
drawPoints: false,
barChart: { width: 30, align: 'center' }
}
},
timeline: {
timelineOptions: {},
nodSelector: () => false,
edgeSelector: edge => edge.getData('type') === CONNECTS_TO
}
});
timelinePlugin.once('timechange', () => {
const { start, end } = timelinePlugin.getWindow();
timelinePlugin.addTimeBar(start);
timelinePlugin.addTimeBar(end);
});
const filter = ogma.transformations.addEdgeFilter({
criteria: edge => {
return timelinePlugin.filteredEdges.has(edge.getId());
},
duration: 500
});
// Hook it to the timeline events
timelinePlugin.on('timechange', () => {
filter.refresh();
});
let isSelecting = false;
timelinePlugin.on(
'select',
({ nodes, edges }: { nodes: NodeList; edges: EdgeList }) => {
isSelecting = true;
if (!edges.size) return;
ogma.getNodes().setSelected(false);
ogma.getEdges().setSelected(false);
const edgeIds = new Set(
edges
.map(edge =>
edge.isVisible() ? edge.getId() : edge.getMetaEdge()?.getId()
)
.filter(e => e)
) as unknown as EdgeId[];
const edgeToSelect = ogma.getEdges(Array.from(edgeIds));
const bounds = edgeToSelect.getBoundingBox();
edgeToSelect.setSelected(true);
if (bounds.width < 256) {
bounds.pad(256 - bounds.width);
}
ogma.view.moveToBounds(bounds, {
easing: 'quadraticInOut',
duration: 1000
});
isSelecting = false;
}
);
ogma.events.on(
['nodesSelected', 'edgesSelected', 'nodesUnselected', 'edgesUnselected'],
() => {
if (isSelecting) return;
timelinePlugin.setSelection({
nodes: ogma.getSelectedNodes(),
edges: ogma.getSelectedEdges()
});
const minMax = ogma
.getSelectedNodes()
.getData('start')
.concat(ogma.getSelectedEdges().getData('start'))
.reduce(
(acc, time) => {
if (time < acc.min) acc.min = time;
if (time > acc.max) acc.max = time;
return acc;
},
{ min: Infinity, max: -Infinity }
);
const year = 1000 * 60 * 60 * 24 * 365;
// add one year margin between min and max
minMax.min -= year;
minMax.max += year;
timelinePlugin.setWindow(minMax.min, minMax.max);
}
);
const panel = document.querySelector('.panel')!;
const observer = new MutationObserver(() => {
const interval = setInterval(() => {
timelinePlugin.barchart.redraw();
timelinePlugin.timeline.redraw();
}, 20);
setTimeout(() => {
clearInterval(interval);
}, 200);
});
observer.observe(panel, {
attributes: true,
attributeFilter: ['class']
});
window.timeline = timelinePlugin;
}
ts
// node types
export const PORT = 'port';
export const SERVER = 'server';
export const IP = 'ip';
// edge types
export const LISTENS_TO = 'listens_to';
export const CONNECTS_TO = 'connects_to';
export type CSVRow = [
number, // timestamp
string, // src_ip
string, // dest_ip
string, // dest_port
string, // protocol
string, // service
string, // duration
string | number, // out_bytes
string | number, // in_bytes
string // status
];
export type ServerData = {
ip: string;
type: typeof SERVER;
};
export type PortData = {
ip: string;
port: string;
type: typeof PORT;
};
export type IPData = {
ip: string;
type: typeof IP;
};
export type ListenData = {
type: typeof LISTENS_TO;
};
export type ConnectData = {
type: typeof CONNECTS_TO;
start: number;
query_bytes: number;
response_bytes: number;
protocol: string;
service: string;
};
export type ND = ServerData | PortData | IPData;
export type ED = ListenData | ConnectData;
ts
// dummy icon element to retrieve the HEX code, it should be hidden
const placeholder = document.createElement('span');
document.body.appendChild(placeholder);
placeholder.style.visibility = 'hidden';
// helper routine to get the icon HEX code
export function getIconCode(className: string) {
placeholder.className = className;
const code = getComputedStyle(placeholder, ':before').content;
return code[1];
}
// format traffic values
export const formatPutThrough = (bytes: number) => {
bytes = +bytes;
return bytes > 1024 * 1024
? Math.round(bytes / (1024 * 1024)) + 'M'
: bytes > 1024
? Math.round(bytes / 1024) + 'KB'
: bytes + 'B';
};