import { css, html, LitElement, PropertyValueMap, TemplateResult } from 'lit'; import { LitElementWw } from '@webwriter/lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { networkStyles } from './styles/network'; import { toolboxStyles } from './styles/toolbox'; import { contextMenuStyles } from './styles/contextmenu'; import { simulationMenuStyles } from './styles/simulationmenu'; import { GraphNodeFactory } from './event-handlers/component-manipulation'; import { EdgeController } from './event-handlers/edge-controller'; import { DialogFactory } from './event-handlers/dialog-content'; import { SubnettingController } from './event-handlers/subnetting-controller'; import { Net, SubnettingMode } from './components/logicalNodes/Net'; import { PacketSimulator } from './event-handlers/packet-simulator'; import { ImportExportController } from './exporting/importExportController'; import { biBoxes, biBroadcastPin, biCloudArrowUp, biCloudCheck, biCloudPlus, biDiagram3, biHdd, biPcDisplayHorizontal, biPencil, biPerson, biPhone, biRouter, biShare, biTrash, faPlus, iBridge, iHub, iSwitch, biFullscreenMaximize, biFullscreenMinimize, } from './styles/icons'; import 'cytoscape-context-menus/cytoscape-context-menus.css'; import { initNetwork } from './network-config'; import { EventObject } from 'cytoscape'; import { contextMenuTemplate } from './ui/ContextMenu'; import '@shoelace-style/shoelace/dist/themes/light.css'; import SlButton from '@shoelace-style/shoelace/dist/components/button/button.component.js'; import SlDetails from '@shoelace-style/shoelace/dist/components/details/details.component.js'; import SlInput from '@shoelace-style/shoelace/dist/components/input/input.component.js'; import SlCheckbox from '@shoelace-style/shoelace/dist/components/checkbox/checkbox.component.js'; import SlTooltip from '@shoelace-style/shoelace/dist/components/tooltip/tooltip.component.js'; import SlButtonGroup from '@shoelace-style/shoelace/dist/components/button-group/button-group.component.js'; import SlAlert from '@shoelace-style/shoelace/dist/components/alert/alert.component.js'; import SlSelect from '@shoelace-style/shoelace/dist/components/select/select.component.js'; import SlOption from '@shoelace-style/shoelace/dist/components/option/option.component.js'; import SlDialog from '@shoelace-style/shoelace/dist/components/dialog/dialog.component.js'; import SlColorPicker from '@shoelace-style/shoelace/dist/components/color-picker/color-picker.component.js'; import SlPopup from '@shoelace-style/shoelace/dist/components/popup/popup.component.js'; import SlTabGroup from '@shoelace-style/shoelace/dist/components/tab-group/tab-group.component.js'; import SlTab from '@shoelace-style/shoelace/dist/components/tab/tab.component.js'; import SlTabPanel from '@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.component.js'; import { MacAddress } from './adressing/MacAddress'; import { simulationMenuTemplate } from './ui/SimulationMenu'; import { Component, Connection, load, Network, setupListeners } from './utils/setup'; import { SlChangeEvent } from '@shoelace-style/shoelace'; import { styleMap } from 'lit/directives/style-map.js'; import LOCALIZE from "../localization/generated"; import { localized, msg } from '@lit/localize'; /** * @summary Visualization of network topologies. Can represent different kinds of networks. * * @tag ww-network * @tagname ww-network * * @attr {boolean} [automate=false] - Enables automation-related UI for subnetting. * @attr {'small'|'medium'} [screen='medium'] - Size forwarded to Shoelace controls. * * @prop {any} [selectedObject] - The currently selected Cytoscape element (node or edge). * @prop {Array} [components=[]] - Serialized node list. * @prop {Array} [connections=[]] - Serialized edges between components. * @prop {Array} [networks=[]] - Serialized logical networks. * * @csspart options - Sidebar container for import/export and controllers. */ @localized() @customElement('ww-network') export class NetworkComponent extends LitElementWw { /** @internal Localization bundle. */ public localize = LOCALIZE; /** @internal Reference to the Cytoscape container element. */ @query('#cy') accessor _cy: any; /** @internal Cytoscape instance. */ _graph: any; /** @internal ID of the component type currently selected in the toolbox. */ currentComponentToAdd: string = ''; /** @internal Current color for color-picking mode. */ currentColor: string = 'white'; /** @internal Preset color palette for node styling. */ colors = [ 'Plum', '#BAADD1', '#9CB6D6', '#9DCBD1', 'LightSeaGreen', '#5FCCAB', '#ADE07A', '#E2E379', 'Tomato', '#FFA6B4', '#FF938B', '#FFA07A', '#8A8A8A', '#A6A6A6', '#D4B6A0', '#C29C8D', ]; /** @internal Indicates whether Cytoscape has been initialized and is available. */ networkAvailable: Boolean = false; /** @internal Controller handle for Cytoscape edgehandles extension. */ _edgeHandles: any; /** @internal Whether edge draw mode is active. */ drawModeOn: boolean = false; /** @internal Controller handle for Cytoscape context menu extension. */ _menu: any; /** @internal Controller handle for Cytoscape drag-and-drop compound nodes extension. */ _cdnd: any; /** @internal Whether color reset mode is active. */ resetColorModeOn: boolean = false; /** @internal IPv4 address registry (address -> node id). Used during simulation/setup. */ ipv4Database: Map = new Map(); /** @internal MAC address registry (address -> node id). Used to guarantee uniqueness. */ macDatabase: Map = new Map(); /** @internal IPv6 address registry (address -> node id). */ ipv6Database: Map = new Map(); /** @internal Stateful packet simulation controller. */ @state() accessor packetSimulator: PacketSimulator = new PacketSimulator(this); /** @internal Stateful subnetting controller. */ @state() accessor subnettingController: SubnettingController = new SubnettingController(this); /** * Enables automation-related UI. */ @property({ type: Boolean, reflect: true }) accessor automate: boolean = false; /** * Controls the default size of Shoelace controls within this widget. */ @property({ type: String, reflect: true }) accessor screen: 'small' | 'medium' = 'medium'; /** * The currently selected Cytoscape element (node or edge). */ @property({ type: Object }) accessor selectedObject: any; /** @internal Toolbox button container in the shadow root. */ @query('#toolboxButtons') accessor toolboxButtons!: HTMLElement; /** @internal Custom context menu root in the shadow root. */ @query('#contextMenu') accessor contextMenu!: HTMLElement; /** * @internal * Edge endpoint metadata captured from the selected edge. Used by the context menu to * display and adjust connection type and port numbers. */ @state() accessor selectedPorts: { source: { connectionType: 'ethernet' | 'wireless' | null; port: number | null; }; target: { connectionType: 'ethernet' | 'wireless' | null; port: number | null; }; } = { source: { connectionType: null, port: 0 }, target: { connectionType: null, port: 0 }, }; /** * @internal * Mutex state to keep exclusive drag-and-drop modes ('subnetting' or 'gateway') mutually exclusive. * null means no drag-and-drop mode is active. */ @state() accessor mutexDragAndDrop: string | null = null; /** * Serialized components (nodes). Used to populate the canvas and for export. */ @property({ type: Array, reflect: true, attribute: true }) accessor components: Array = []; /** * Serialized connections (edges) between components. */ @property({ type: Array, reflect: true, attribute: true }) accessor connections: Array = []; /** * Serialized logical networks for subnetting and validation. */ @property({ type: Array, reflect: true, attribute: true }) accessor networks: Array = []; /** @internal Current widget mode. 'edit' enables graph editing, 'simulate' locks nodes and runs simulations. */ @state() accessor mode: 'edit' | 'simulate' = 'edit'; /** @internal Current subnetting mode. Managed by SubnettingController/Net utilities. */ @state() accessor subnettingMode: SubnettingMode = 'MANUAL'; net_mode: SubnettingMode = 'MANUAL'; /** * Component styles composed from network canvas, toolbox, context menu, and simulation menu stylesheets. */ public static get styles() { return [networkStyles, toolboxStyles, contextMenuStyles, simulationMenuStyles]; } /** * Shadow root options. */ static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }; /** * Scoped element registry for Shoelace components used in the template. */ public static get scopedElements() { return { 'sl-button': SlButton, 'sl-button-group': SlButtonGroup, 'sl-details': SlDetails, 'sl-input': SlInput, 'sl-checkbox': SlCheckbox, 'sl-tooltip': SlTooltip, 'sl-alert': SlAlert, 'sl-select': SlSelect, 'sl-option': SlOption, 'sl-dialog': SlDialog, 'sl-color-picker': SlColorPicker, 'sl-popup': SlPopup, 'sl-tab-group': SlTabGroup, 'sl-tab': SlTab, 'sl-tab-panel': SlTabPanel, }; } /** * Returns true when the host has contentEditable enabled ('' or 'true'), enabling authoring controls. * @internal */ public isEditable(): boolean { return this.contentEditable === 'true' || this.contentEditable === ''; } /** * Lit lifecycle hook: invoked after the component's DOM is first rendered. * * @param _changedProperties Map of changed properties since the last update. * @internal */ protected firstUpdated(_changedProperties: PropertyValueMap | Map): void { super.firstUpdated(_changedProperties); initNetwork(this); this._graph.on('cxttap', (event: EventObject) => { event.preventDefault(); if (event.target === this._graph) { this.contextMenu.style.display = 'none'; return; } this.selectedObject = event.target; if (!this.selectedObject.isNode()) { const edge = this.selectedObject.data(); this.selectedPorts = { source: { connectionType: null, port: 0 }, target: { connectionType: null, port: 0 }, }; if (edge.inPort != undefined && edge.inPort != null && !Number.isNaN(edge.inPort)) { this.selectedPorts.source.port = edge.inPort; this.selectedPorts.source.connectionType = edge.from.portData .get(edge.inPort) .get('Connection Type'); } if (edge.outPort != undefined && edge.outPort != null && !Number.isNaN(edge.outPort)) { this.selectedPorts.target.port = edge.outPort; this.selectedPorts.target.connectionType = edge.to.portData .get(edge.outPort) .get('Connection Type'); } } console.log(this.selectedObject.data()); this.contextMenu.style.display = 'block'; this.contextMenu.style.left = event.renderedPosition.x + 'px'; this.contextMenu.style.top = event.renderedPosition.y + 'px'; }); this._graph.on('tap', (event: EventObject) => { const t = this.selectedObject; this.selectedObject = null; this.selectedObject = t; this.contextMenu.style.display = 'none'; }); this._graph.on('drag', (event: EventObject) => { const t = this.selectedObject; this.selectedObject = null; this.selectedObject = t; this.contextMenu.style.display = 'none'; }); load.bind(this)(); setupListeners.bind(this)(); window.addEventListener('scroll', this.onScroll); } /** * Lit lifecycle hook: cleanup when the element is disconnected. * Removes the scroll listener added in firstUpdated. * @internal */ disconnectedCallback() { super.disconnectedCallback(); window.removeEventListener('scroll', this.onScroll); } /** * @internal * Workaround for Cytoscape's cached container bounds. Forces a resize to ensure input coordinates * are mapped correctly after the page scrolls. */ private onScroll = () => { // Cytoscape caches container bounds which become outdated after scrolling. // Calling resize() forces Cytoscape to update its internal bounds and correctly map mouse input. this._graph.resize() } /** * Whether the editor is in fullscreen mode. * Uses the Fullscreen API to determine if the element itself is the fullscreen element. * @private */ private get isFullscreen(): boolean { return this.ownerDocument.fullscreenElement === this; } /** * Toggles fullscreen mode using the Fullscreen API and requests a re-render afterwards. * Safely catches errors when entering fullscreen is not allowed. * @private */ private async handleFullscreenToggle() { if (this.isFullscreen) { await this.ownerDocument.exitFullscreen(); this.requestUpdate() } else { try { await this.requestFullscreen(); this.requestUpdate() } catch (error) { console.error("Failed to enter fullscreen mode."); } } } /** * Renders the network canvas, mode switch, toolbox, context menu and simulation menu. */ public render(): TemplateResult { return html` ${this.isEditable() ? this.asideTemplate() : null}
{ const mode = (event.target as HTMLSelectElement).value as 'edit' | 'simulate'; if (mode === 'edit') { const components = [...this.components]; const connections = [...this.connections]; const networks = [...this.networks]; this.ipv4Database = new Map(); //(address, nodeId) this.macDatabase = new Map(); this.ipv6Database = new Map(); console.log(components, connections, networks); this._graph.elements().remove(); this.components = components; this.connections = connections; this.networks = networks; load.bind(this)(); } else { this._graph.$('node').lock(); this.packetSimulator.initSession(this); } this.mode = mode; }} size="small" > ${this.mode === 'edit' ? biPencil : biBoxes} ${msg("Edit")} ${msg("Simulate")}
${this.toolboxTemplate()} ${contextMenuTemplate.bind(this)()} ${simulationMenuTemplate.bind(this)()} this.handleFullscreenToggle()}> ${this.isFullscreen ? biFullscreenMinimize : biFullscreenMaximize}
`; } /** * Renders the floating toolbox used in 'edit' mode. * Contains quick actions to add hosts, network devices, edges, and networks. * @internal */ private toolboxTemplate(): TemplateResult { return html`
this.openToolbox()}> ${faPlus}
${biPerson}
${biPcDisplayHorizontal} ${biPhone}
${biHdd}
${biRouter} ${biBroadcastPin} ${biHdd} ${iHub} ${iBridge} ${iSwitch}
${biShare}
${biDiagram3}
${biCloudPlus} ${biCloudArrowUp} ${biCloudCheck}
`; } /** * Toggles the visibility (collapsed/expanded) of the toolbox by switching the 'closed' CSS class. * @internal */ private openToolbox(): void { this.toolboxButtons.classList.toggle('closed'); } /** * Factory returning actions to add host components to the graph. * - computer(): Adds a wired computer with one ethernet interface and a unique MAC address. * - mobile(): Adds a wireless mobile device with one wireless interface and a unique MAC address. * @internal */ private addHost() { return { computer: () => { GraphNodeFactory.addNode(this, { componentType: 'computer', interfaces: [ { name: 'eth0', connectionType: 'ethernet', mac: MacAddress.generateRandomAddress(this.macDatabase).address, ipv4: '192.168.20.1', ipv6: '0:0:0:0:0:0:0:1', }, ], }); }, mobile: () => { GraphNodeFactory.addNode(this, { componentType: 'mobile', interfaces: [ { name: 'eth0', connectionType: 'wireless', mac: MacAddress.generateRandomAddress(this.macDatabase).address, ipv4: '192.168.20.1', ipv6: '0:0:0:0:0:0:0:1', }, ], }); }, }; } /** * Factory returning actions to add network devices to the graph: * - router(), accessPoint(), repeater(), hub(), bridge(), switch() * @internal */ private addNetworkDevice() { return { router: () => { GraphNodeFactory.addNode(this, { componentType: 'router', interfaces: [], }); }, accessPoint: () => { GraphNodeFactory.addNode(this, { componentType: 'access-point', interfaces: [ { mac: MacAddress.generateRandomAddress(this.macDatabase).address }, { mac: MacAddress.generateRandomAddress(this.macDatabase).address }, ], }); }, repeater: () => { GraphNodeFactory.addNode(this, { componentType: 'repeater', interfaces: [{ connectionType: 'ethernet' }, { connectionType: 'ethernet' }], }); }, hub: () => { GraphNodeFactory.addNode(this, { componentType: 'hub', interfaces: [], }); }, bridge: () => { GraphNodeFactory.addNode(this, { componentType: 'bridge', interfaces: [ { connectionType: 'ethernet', mac: MacAddress.generateRandomAddress(this.macDatabase).address }, { connectionType: 'ethernet', mac: MacAddress.generateRandomAddress(this.macDatabase).address }, ], }); }, switch: () => { GraphNodeFactory.addNode(this, { componentType: 'switch', interfaces: [ { mac: MacAddress.generateRandomAddress(this.macDatabase).address }, { mac: MacAddress.generateRandomAddress(this.macDatabase).address }, ], }); }, }; } private addEdge() {} /** * Adds a logical network node with default CIDR settings. * @internal */ private addNetwork() { GraphNodeFactory.addNode(this, { componentType: 'net', net: { netid: '1.1.1.0', netmask: '255.255.255.0', bitmask: 24, }, }); } /** * Renders the authoring sidebar. * @internal * * @csspart options - Wrapper around authoring controls. */ private asideTemplate(): TemplateResult { return html` `; } /** * Highlights the selected component group in the sidebar and switches to the appropriate tab panel. * @internal * * @param e Click event from a component button within the authoring sidebar. */ private clickOnComponentButton(e: Event): void { this.currentComponentToAdd = (e.target as HTMLElement).getAttribute('id'); let nodeToHighLight: string = ''; let panelToActive: string = ''; switch (this.currentComponentToAdd) { case 'computer': case 'mobile': nodeToHighLight = 'host'; panelToActive = 'physical'; break; case 'router': case 'access-point': case 'hub': case 'repeater': case 'bridge': case 'switch': nodeToHighLight = 'connector'; panelToActive = 'physical'; break; case 'net': nodeToHighLight = 'net'; panelToActive = 'logical'; default: nodeToHighLight = this.currentComponentToAdd; break; } this.renderRoot.querySelectorAll('.btn').forEach((e) => { if (e.id == nodeToHighLight) { //highlight the chosen component (e as HTMLElement).style.border = 'solid 2px #404040'; } else { //un-highlight other components (e as HTMLElement).style.border = 'solid 1px transparent'; } }); if (panelToActive != '') { (this.renderRoot.querySelector('#physical-logical-group') as SlTabGroup).show(panelToActive); } } /** * Lit lifecycle hook: reacts to property changes to keep UI/graph state in sync. * @internal */ updated(changedProperties: Map) { if (changedProperties.has('contentEditable')) { // new value is const newValue = this.isEditable(); if (newValue) { if (this.networkAvailable) this._graph.elements().toggleClass('deletable', true); ['host', 'connector', 'edge', 'net', 'addCompBtn', 'drawBtn'].forEach((buttonId) => { if (this.renderRoot.querySelector('#' + buttonId)) (this.renderRoot.querySelector('#' + buttonId) as HsTMLButtonElement).disabled = false; }); } else { if (this.networkAvailable) this._graph.elements().toggleClass('deletable', false); ['host', 'connector', 'edge', 'net', 'addCompBtn', 'drawBtn'].forEach((buttonId) => { if (this.renderRoot.querySelector('#' + buttonId)) (this.renderRoot.querySelector('#' + buttonId) as HTMLButtonElement).disabled = true; }); } } if (changedProperties.has('automate')) { // new value is const newValue = this.automate; if (newValue) { (this.renderRoot.querySelector('#current-subnet-mode') as SlSelect).disabled = false; } else { (this.renderRoot.querySelector('#current-subnet-mode') as SlSelect).value = 'MANUAL'; (this.renderRoot.querySelector('#current-subnet-mode') as SlSelect).disabled = true; Net.setMode('MANUAL', this); } } } }