Skip to content
  1. Examples

Cyber security log analysis

tsx
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOMServer from 'react-dom/server';
import { createRoot } from 'react-dom/client';
import App from './App';
import './style.css';

const root = createRoot(document.getElementById('root')!);
root.render(<App />);
html
<!doctype html>
<html lang="en">
  <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>
    <!-- this importmap is only needed for Ogma playground -->
    <script type="importmap">
      {
        "imports": {
          "@shoelace-style/shoelace": "https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/shoelace.js",
          "@shoelace-style/shoelace/dist/react": "https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/react/index.js",
          "@shoelace-style/shoelace/react/checkbox": "https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/react/checkbox/index.js",
          "@shoelace-style/shoelace/react/checkbox": "https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/react/checkbox/index.js",
          "@lit/react": "https://cdn.jsdelivr.net/npm/@lit/react/+esm"
        }
      }
    </script>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./index.tsx"></script>
  </body>
</html>
css
body {
  margin: 0;
  padding: 0;
  width: 100vw;
  height: 100vh;
  font-family: IBM Plex Sans;
  font-size: 12px;
}

#root {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
}

.ogma-container {
  width: 100%;
  height: 100%;
}
json
{
  "name": "@linkurious/cyber-security-react",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@linkurious/ogma-react": "latest",
    "react": "19.1.0",
    "react-dom": "19.1.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.4.1",
    "vite": "^6.3.5"
  }
}
tsx
import React from 'react';
import { Ogma } from '@linkurious/ogma-react';
import OgmaLib, { RawGraph } from '@linkurious/ogma';

import { AppProvider } from './Context';
import { Panel } from './Panel';
import { recordsToGraph } from './parse';
import { CSVRow, EventTypes } from './types';
import { Styles } from './Styles';
import { Timeline } from './Timeline';

import './style.css';

function App() {
  const [graph, setGraph] = React.useState<RawGraph<unknown, unknown>>();
  const [events, setEvents] = React.useState<EventTypes>({});
  const ogmaRef = React.useRef<OgmaLib<unknown, unknown>>(null);

  React.useEffect(() => {
    // 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 => setGraph(graph));
  }, []);

  const onReady = React.useCallback(ogma => {
    ogma.layouts.force({ locate: true });
    ogmaRef.current = ogma;
  }, []);

  if (!graph) return <div>Loading...</div>;

  return (
    <AppProvider>
      <Ogma
        ref={ogmaRef}
        graph={graph}
        onReady={onReady}
        onNodesSelected={events.nodesSelected}
        onEdgesSelected={events.edgesSelected}
        onNodesUnselected={events.nodesUnselected}
        onEdgesUnselected={events.edgesUnselected}
      >
        <Styles />
        <Panel />
        <Timeline setEvents={setEvents} />
      </Ogma>
    </AppProvider>
  );
}

export default App;
tsx
import React from 'react';

interface AppContextType {
  isDarkMode: boolean;
  setIsDarkMode: (isDarkMode: boolean) => void;
}

const AppContext = React.createContext<AppContextType>({
  isDarkMode: false,
  setIsDarkMode: () => {}
});

export const AppProvider: React.FC<React.PropsWithChildren<{}>> = ({
  children
}) => {
  const [isDarkMode, setIsDarkMode] = React.useState(false);

  return (
    <AppContext.Provider value={{ isDarkMode, setIsDarkMode }}>
      {children}
    </AppContext.Provider>
  );
};
export const useAppContext = (): AppContextType => {
  const context = React.useContext(AppContext);
  if (!context) {
    throw new Error('useAppContext must be used within an AppProvider');
  }
  return context;
};
css
ul {
  list-style: none;
  padding: 0;
  margin: 0;
  display: grid;
  grid-template-columns: 16px auto;
  align-items: center;
  gap: 5px 10px;
}

ul > li {
  display: contents;
}

.legend {
  align-self: center;
  justify-self: center;
}

/* legend item styles */
.legend.ip {
  width: 9px;
  aspect-ratio: 1;
  border-radius: 50%;
  background-color: #0094ff;
}

.legend.port {
  width: 12px;
  aspect-ratio: 1;
  background-color: #5bdbe2;
}

.icon-move-right {
  justify-self: center;
  align-self: center;
}

li > svg {
  background: none;
  justify-self: center;
  align-self: center;
}
tsx
import React from 'react';
import './Legend.css';

export const Legend = () => {
  return (
    <ul>
      <li>
        <span className="legend ip"></span>
        <span>IP Address</span>
      </li>

      <li>
        <span className="legend port"> </span>
        <span>Port</span>
      </li>
      <li>
        <svg
          width="12"
          height="12"
          viewBox="-1 -1 14 14"
          xmlns="http://www.w3.org/2000/svg"
        >
          <circle cx="6" cy="6" r="6" stroke="#DE425B" fill="none"></circle>
          <rect x="3" y="3" width="6" height="6" fill="#5bdbe2"></rect>
        </svg>
        <span>Compromised Port</span>
      </li>
      <li>
        <i className="icon-move-right" />
        <span>Listens to</span>
      </li>
    </ul>
  );
};
css
.hide-closed {
  font-size: 13px;
}

.menu-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  position: absolute;
  width: 24px;
  height: 24px;
  padding: 0;
  border: none;
  background-color: white;
  color: black;
  box-shadow: 0px 1px 4px 0px #00000028;
  border-radius: 50%;
  font-size: 18px;
  transform: translateX(200px);
  transition: transform 0.3s ease;
}

.menu-toggle:hover {
  cursor: pointer;
  background-color: #f6f6f6;
}

/* dark mode style */
.menu-toggle.dark {
  background-color: #3c4453;
  box-shadow: 0px 1px 4px 0px #222;
  color: white;
}

.menu-toggle.dark:hover {
  background-color: #353b46;
}

.title {
  font-size: 13px;
  margin-bottom: 12px;
}

sl-divider {
  margin: 14px 0;
}

.panel .title-container {
  display: flex;
  align-items: center;
  gap: 15px;
  margin-bottom: 15px;
}

/* when the panel is closed */
.panel {
  gap: 0;
  bottom: 0;
  top: 15px;
  padding: 21px 16px;
  transition: width 0.3s ease;
}

.panel-left {
  top: 15px;
  height: 420px;
}

.panel-right {
  right: 20px;
  left: unset;
  height: 145px;
  width: 160px;
}

.panel-right .title {
  margin-bottom: 8px;
}

.panel.closed {
  width: 60px; /* Enough for the icons */
  height: 50px;
  top: 15px;
  padding: 11px 0 11px 11px;
}

.panel.closed .title-container {
  margin: 0 8px 0 0;
}

/* Hide everything inside the panel *except* the title */
.panel.closed > *:not(.title-container) {
  display: none;
}

.panel.closed .hide-closed {
  display: none;
}

.panel.closed .menu-toggle {
  position: absolute;
  transform: translateX(36px) rotate(180deg);
  transition: transform 0.3s ease;
}

/* redefine the style for the checkbox labels */
sl-checkbox::part(label) {
  font-size: 12px;
  font-family: 'IBM Plex Sans';
}

.toggle-container {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.title-container > .container {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  padding: 2px;
  border-radius: 5px;
  font-size: 22px;
  color: white;
  background: black;
}

.ports-container {
  display: flex;
  flex-direction: row;
  justify-content: space-between;
}

.ports {
  display: flex;
  flex-direction: column;
  gap: 4px;
  width: 100%;
}
tsx
import React from 'react';
import { useOgma } from '@linkurious/ogma-react';
import { SlSwitch, SlDivider } from '@shoelace-style/shoelace/dist/react';
import { useAppContext } from './Context';
import { PortFilter } from './PortFilter';
import { Legend } from './Legend';
import { Toggle } from './Toggle';

import './Panel.css';

export const Panel = () => {
  const { isDarkMode, setIsDarkMode } = useAppContext();
  const panelRef = React.useRef<HTMLDivElement>(null);
  const ogma = useOgma();

  const leftPorts = [0, 3, 53, 67, 80, 123];

  const rightPorts = [443, 1900, 8080, 16464, 16471, 65243];

  const panelToggler = evt => {
    panelRef.current?.classList.toggle('closed');
    document.querySelector('.timeline')?.classList.toggle('closed');
    evt.currentTarget.title = panelRef.current?.classList.contains('closed')
      ? 'Open Menu'
      : 'Hide Menu';
  };

  const toggleDarkMode = () => {
    document.documentElement.classList.toggle('sl-theme-dark');
    document.querySelector('.menu-toggle')?.classList.toggle('dark');
    ogma.setOptions({
      backgroundColor: document.documentElement.classList.contains(
        'sl-theme-dark'
      )
        ? '#333'
        : undefined
    });
    document.querySelector('.timeline')?.classList.toggle('dark');
    setIsDarkMode(!isDarkMode);
  };

  return (
    <>
      <div className="panel panel-left" ref={panelRef}>
        {/* Title and menu toggler button */}
        <div className="title-container">
          <span className="container">
            <i className="icon-shield-user" />
          </span>
          <span className="hide-closed">Cyber Security Company</span>
          <button
            title="Hide Menu"
            className="menu-toggle"
            onClick={panelToggler}
          >
            <i className="icon-chevron-left" />
          </button>
        </div>

        {/* Ports */}
        <div>
          <div className="title">PORTS</div>
          <div className="ports-container">
            <div className="ports">
              {leftPorts.map(port => (
                <PortFilter key={port} port={port} />
              ))}
            </div>
            <div className="ports">
              {rightPorts.map(port => (
                <PortFilter key={port} port={port} />
              ))}
            </div>
          </div>
        </div>

        <SlDivider role="separator" aria-orientation="horizontal" />

        {/* Connections */}
        <div>
          <div className="title">CONNECTIONS</div>
          <div className="toggle-container">
            <Toggle title="Grouping" checked />
          </div>
        </div>

        <SlDivider role="separator" aria-orientation="horizontal" />
        {/* Theme */}
        <div className="title">THEME</div>
        <div className="toggle-container">
          <span>Dark Mode</span>
          <SlSwitch onSlChange={toggleDarkMode} />
        </div>
      </div>
      <div className="panel panel-right">
        <div className="title">KEY</div>
        <Legend />
      </div>
    </>
  );
};
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,
      out_bytes,
      in_bytes
    ] = 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 };
}
tsx
import React from 'react';
import { SlCheckbox } from '@shoelace-style/shoelace/dist/react';
import { NodeFilter } from '@linkurious/ogma-react';
import { PORT } from './types';

export const PortFilter = (props: { port: number }) => {
  const [checked, setChecked] = React.useState<boolean>(true);

  return (
    <>
      <SlCheckbox checked={checked} onSlChange={() => setChecked(!checked)}>
        {props.port}
      </SlCheckbox>
      <NodeFilter
        criteria={node =>
          node.getData('type') !== PORT ||
          node.getData('port') !== props.port.toString() ||
          checked
        }
      />
    </>
  );
};
tsx
import React from 'react';
import { EdgeStyle, NodeStyle } from '@linkurious/ogma-react';
import { CONNECTS_TO, IP, LISTENS_TO, PORT, SERVER } from './types';
import { formatPutThrough, getIconCode } from './utils';
import { useAppContext } from './Context';

export const Styles = () => {
  const { isDarkMode } = useAppContext();
  const edgeColor = isDarkMode ? '#80F0F6' : '#007C83';
  return (
    <>
      {/* Style for server nodes */}
      <NodeStyle
        selector={node => node.getData('type') === SERVER}
        attributes={{
          radius: 10,
          color: '#0094ff',
          icon: {
            font: 'Lucide',
            content: getIconCode('icon-server'),
            color: '#fff',
            style: 'bold',
            minVisibleSize: 0,
            scale: 0.7
          }
        }}
      />

      {/* Style for IP nodes */}
      <NodeStyle
        selector={node => node.getData('type') === IP}
        attributes={{
          innerStroke: '#0094ff',
          color: '#0094ff',
          // 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: '#fff',
            minVisibleSize: 0,
            scale: 0.4
          },
          text: {
            content: node => node.getData('ip'),
            color: '#fff',
            secondary: {
              // number of outgoing “CONNECTS_TO” edges (taking filters into account)
              content: node =>
                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) => total + edge.getData('response_bytes'),
                      0
                    ) as number
                ) +
                ' 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 => edge.getData('type') === CONNECTS_TO)
                    .reduce(
                      (total, edge) => total + edge.getData('query_bytes'),
                      0
                    ) as number
                ) +
                ' sent\n',
              color: '#0094ff',
              backgroundColor: 'white',
              minVisibleSize: 60,
              margin: 1
            },
            backgroundColor: '#0094ff'
          }
        }}
      />

      {/* Style for Port nodes */}
      <NodeStyle
        selector={node => node.getData('type') === PORT}
        attributes={{
          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
          }
        }}
      />

      {/* Style for hovered nodes */}
      <NodeStyle.Hovered
        attributes={{
          outerStroke: {
            width: 2,
            color: '#ff6c52',
            scalingMethod: 'fixed'
          },

          text: {
            backgroundColor: '0094ff'
          }
        }}
      />

      {/* Style for selected nodes */}
      <NodeStyle.Selected
        attributes={{
          outerStroke: {
            width: 2,
            color: '#ff6c52',
            scalingMethod: 'fixed'
          },
          text: {
            style: 'bold',
            backgroundColor: '0094ff'
          }
        }}
      />

      {/* Default style for edges */}
      <EdgeStyle
        attributes={{
          color: edge =>
            edge.getData('type') === CONNECTS_TO ? '#0094ff' : edgeColor,
          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;
              }
            }
          }
        }}
      />

      {/* Style for hovered edges */}
      <EdgeStyle.Hovered
        attributes={{
          color: '#ff6c52',
          text: {
            backgroundColor: 'white'
          }
        }}
      />

      {/* Style for selected edges */}
      <EdgeStyle.Selected
        attributes={{
          color: '#ff6c52',
          text: {
            style: 'bold',
            backgroundColor: 'white'
          }
        }}
      />
    </>
  );
};
css
.timeline {
  width: 100%;
  height: 150px;
  transform: translate(0, -150px);
  background-color: white;
  transition: all 0.3s ease-in-out;
}

/* dark mode */
.timeline.dark {
  background-color: #3c4453;
}

/* when the panel is closed, center the timeline */
.timeline.closed {
  width: 100%;
  transform: translate(0, -150px);
}

.vis-timeline .vis-bar {
  fill-opacity: 1 !important;
}

/* redefine the text color of the labels in the timeline for dark mode */
.sl-theme-dark .vis-data-axis .vis-y-axis.vis-minor,
.sl-theme-dark .vis-data-axis .vis-y-axis.vis-major,
.sl-theme-dark .vis-time-axis .vis-text {
  color: #fff;
}

/* redefine the color of the separators in the timeline */
.sl-theme-dark .vis-panel.vis-background.vis-horizontal .vis-grid,
.sl-theme-dark .vis-grid.vis-vertical {
  border-color: rgba(179, 181, 219, 0.267);
}

.vis-timeline {
  box-shadow: 0 1px 4px #0006;
  border: none;
}

.vis-text,
.vis-data-axis {
  font-family: 'IBM Plex Sans', sans-serif;
}
tsx
import React from 'react';
import { Controller as TimelinePlugin } from '@linkurious/ogma-timeline-plugin';
import { EdgeFilter as EdgeFilterC, useOgma } from '@linkurious/ogma-react';
import { EdgeFilter, EdgeId, EdgeList } from '@linkurious/ogma';
import { CONNECTS_TO, EventTypes } from './types';
import './Timeline.css';

export const Timeline = (props: {
  setEvents: (events: EventTypes) => void;
}) => {
  const ogma = useOgma();
  const timelineRef = React.useRef<HTMLDivElement>(null);
  const pluginRef = React.useRef<TimelinePlugin>(null);
  const isSelectingRef = React.useRef(false);
  const filterRef = React.useRef<EdgeFilter<unknown, unknown>>(null);
  const events: EventTypes = {
    nodesSelected: () => {},
    edgesSelected: () => {},
    nodesUnselected: () => {},
    edgesUnselected: () => {}
  };

  React.useEffect(() => {
    const timelinePlugin = new TimelinePlugin(ogma, timelineRef.current!, {
      barchart: {
        graph2dOptions: {
          drawPoints: false,
          barChart: { width: 30, align: 'center' }
        }
      },
      timeline: {
        timelineOptions: {}
      }
    });

    pluginRef.current = timelinePlugin;

    timelinePlugin.on('timechange', () => {
      filterRef.current!.refresh();
    });

    timelinePlugin.once('timechange', () => {
      const { start, end } = timelinePlugin.getWindow();
      timelinePlugin.addTimeBar(start);
      timelinePlugin.addTimeBar(end);
    });

    timelinePlugin.on('select', evt => {
      const edges = evt.edges as EdgeList;
      isSelectingRef.current = 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
      });
      isSelectingRef.current = false;
    });

    Object.keys(events).forEach(eventName => {
      const event = () => {
        if (isSelectingRef.current) 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);
      };
      events[eventName] = event;
    });
    props.setEvents(events);
  }, []);

  React.useEffect(() => {
    if (filterRef.current) filterRef.current.setIndex(0);
  }, [filterRef.current]);

  const filter = React.useCallback(
    edge => {
      return (
        edge.getData('type') !== CONNECTS_TO ||
        pluginRef.current!.filteredEdges.has(edge.getId())
      );
    },
    [pluginRef.current]
  );

  return (
    <>
      <div className="timeline" ref={timelineRef} />
      <EdgeFilterC ref={filterRef} criteria={filter} />
    </>
  );
};
tsx
import React from 'react';
import { SlSwitch } from '@shoelace-style/shoelace/dist/react';
import { EdgeGrouping } from '@linkurious/ogma-react';
import { CONNECTS_TO } from './types';

// This is a separate component to not refresh the whole panel when the switch is toggled
export const Toggle = (props: { title: string; checked: boolean }) => {
  const [checked, setChecked] = React.useState<boolean>(props.checked);

  return (
    <>
      <span>{props.title}</span>
      <SlSwitch checked={checked} onSlChange={() => setChecked(!checked)} />
      <EdgeGrouping
        disabled={!checked}
        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: any) => {
            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
            }
          };
        }}
      />
    </>
  );
};
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;

export type EventTypes = {
  nodesSelected?: () => void;
  edgesSelected?: () => void;
  nodesUnselected?: () => void;
  edgesUnselected?: () => void;
}
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';
};
ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()]
});