Skip to content
  1. Examples

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';
};