Network Mapper Developer Guide - edmozley/freeitsm GitHub Wiki
What it does for the analyst: Network Mapper · on a phone: Mobile-Friendly: Network Mapper · over the API: REST API: Network Mapper
A diagram is a set of nodes, each one a pointer to a real CMDB object (cmdb_objects), plus connectors between them. The editor is one hand-written JavaScript module (window.NM) that keeps the whole diagram in memory, draws nodes as positioned <div>s and connectors in an SVG layer, and saves by sending the whole diagram back. The server deletes the old nodes and connectors and inserts the new ones, so every save gives every node a new id. Versions are a linked list of network_diagrams rows; only the newest one (the leaf) can be written to.
The user page already covers every feature. This page is about how the code is put together.
| File | What it is |
|---|---|
network-mapper/index.php |
The diagram list - cards, filter, New modal, Delete. Inline script; calls list_diagrams.php, create_diagram.php, delete_diagram.php
|
network-mapper/diagram.php |
The editor shell (?id=N, optional ?fit=1). Mostly CSS and markup; the inline script at the bottom calls NM.init(), wires Ctrl+S, Escape (closes every modal and dropdown), click-outside on the modals and the beforeunload warning |
network-mapper/help.php |
The in-app guide |
network-mapper/includes/header.php |
Module header (waffle menu, one nav tab: Diagrams) |
assets/js/network-mapper.js |
The editor - everything live. One IIFE, public surface on window.NM
|
assets/js/network-mapper-icons.js |
window.NM_ICONS, NM_ICON_META, NM_ICON_CATEGORIES and nmRenderIcon(key, size, extra)
|
assets/js/vendor/html2canvas.min.js, assets/js/pdf-writer.js
|
PNG / PDF export: html2canvas takes the picture, the shared PDF writer (PDFKit, vendored) places it in the PDF - see PDF writer - Developer Guide |
includes/services/network_mapper.php |
NetworkMapperService - the write rules, shared by the UI endpoints, the REST API and the CMDB blast-radius handoff |
api/network-mapper/*.php |
Eight UI endpoints (below) |
api/v1/resources/network_mapper.php |
The REST resource, including the API-only incremental node / connector endpoints |
api/cmdb/create_impact_diagram.php |
"Open as diagram" from the CMDB blast radius - creates a diagram through NetworkMapperService::createDiagram()
|
Three tables, all install-wide (no tenant_id). Columns from includes/db_verify_schema.php; foreign keys from database/freeitsm.sql.
| Table | Columns |
|---|---|
network_diagrams |
id, parent_diagram_id (self-FK, ON DELETE SET NULL), title, description, version_label (50), created_by_analyst_id, created_datetime, updated_datetime, paper_size, paper_orientation, header_left / header_center / header_right / footer_left / footer_center / footer_right (200 each, NULL = inherit the org default, '' = explicit blank), is_demo
|
network_diagram_nodes |
id, diagram_id (cascade), cmdb_object_id, x, y, size (small / medium / large), icon_override, is_demo
|
network_diagram_connectors |
id, diagram_id (cascade), from_node_id / to_node_id (cascade from the node), cmdb_relationship_id (ON DELETE SET NULL to cmdb_object_relationships), label, line_style (solid / dashed), is_demo
|
The node holds no name, class or icon - those are read from the CMDB on every load. That is the whole point: rename the CI and the diagram follows.
get_diagram.php?id=N returns this (field names are exact, values illustrative):
{
"success": true,
"diagram": {
"id": 12, "parent_diagram_id": 9, "title": "Core network", "description": null,
"version_label": "v2", "created_by_analyst_id": 3, "author_name": "Ed",
"created_datetime": "2026-10-01 09:00:00", "updated_datetime": "2026-10-02 14:10:00",
"paper_size": "A3", "paper_orientation": "landscape",
"header_left": null, "header_center": null, "header_right": null,
"footer_left": null, "footer_center": null, "footer_right": null,
"child_count": 0, "is_current": true
},
"nodes": [
{ "id": 301, "cmdb_object_id": 41, "x": 160, "y": 80, "size": "medium", "icon_override": null,
"name": "core-sw-01", "is_planned": false,
"class_id": 5, "class_name": "Switch", "class_icon": "switch" }
],
"connectors": [
{ "id": 77, "from_node_id": 301, "to_node_id": 302,
"cmdb_relationship_id": 18, "label": "connects to", "line_style": "solid" }
]
}loadDiagram() puts these straight into three module variables: diagram, nodes, connectors. Everything the editor does is a change to those arrays followed by a re-render.
Unsaved things get negative ids. A node placed from the picker (commitPickerSelection()) or from Add related objects (commitRelatedSelections()) has id: null and tempId: nextTempId-- (-1, -2, ...). A connector's from_node_id / to_node_id hold whichever the node has - nodeRef(n) returns n.id || n.tempId, and findNodeByRef() matches either. A new connector also gets a _tempKey that is only used for selection and is never sent.
Two kinds of key, do not mix them up:
| Function | Gives | Used for |
|---|---|---|
nodeRef(n) |
a number - real id or negative tempId | connector ends, and what the server maps |
nodeKey(n) |
a string - 'r301' or 't-1'
|
selection (selectedNodeKey) and the node element's data-key
|
connectorKey(c) |
'cr77' or 'ct-3'
|
connector selection (selectedConnectorKey) |
HTML for nodes, SVG for lines. Not a canvas element.
-
#canvas(.nm-canvas) is the scroll viewport and carries the dot grid. It is not zoomed. -
#canvasInner(.nm-canvas-inner) holds everything that is: the SVG layer, the node<div>s, the page outline and the branding strips.applyZoom()setstransform: scale(zoom)on it and grows#canvasSpacerto3000 × zoompx so the viewport can still scroll. Zoom steps areZOOM_LEVELS(0.25 to 3);setZoom()snaps to the nearest one. -
ensureSvgLayer()creates one 5000 × 5000<svg class="nm-svg-layer">as the first child of#canvasInner, with two arrowhead markers (nm-arrow, and cyannm-arrow-selfor the selected line). -
renderNodes()removes every.nm-nodeand rebuilds them withbuildNodeEl(): the icon (nmRenderIcon(icon_override || class_icon || 'box')), the PLANNED pill, the label and - on the current version only - four.nm-edge-handledots. It always ends by callingrenderConnectors(). -
renderConnectors()removes every.nm-connector-groupand draws each connector as a<g>: a wide invisible.nm-connector-hitpath (the click target), the visible.nm-connector-linepath, and if labelled a background<rect>and<text>at the midpoint. -
calcConnectorPoints()picks the side of each icon box (nodeIconBBox(), not the whole node with its label) that faces the other node, on whichever axis has the bigger gap. Lines are straight.
All coordinates are model coordinates at 1×. canvasModelCoords(e) turns a mouse event into them: offset from #canvas, plus its scroll, divided by zoom. Every drop, drag and hit test goes through it, which is why zoom never breaks dragging.
| Gesture | Code |
|---|---|
| Drag a class from the palette | HTML5 drag. onTileDragStart() puts {kind: 'nm-class', class_id} on dataTransfer; onCanvasDrop() snaps the drop point to the 20px GRID and calls openObjectPicker()
|
| Pick the object |
fetchPickerResults() calls api/cmdb/get_objects.php?class_id= (with &search= after a 200ms debounce). renderPickerResults() hides objects already on the canvas. onPickerKeyDown() handles arrows / Enter / Escape |
| Select |
selectNode(key) / selectConnector(key) - mutually exclusive. Selecting a node opens the detail panel (openDetailForNode()), which lazy-loads api/cmdb/get_object.php; currentPropertiesObjectId makes a slow reply for the previous node be ignored |
| Move |
onNodeMouseDown() records nodeDrag, then document mousemove / mouseup. onNodeMouseMove() snaps, moves the element and redraws all connectors. onNodeMouseUp() calls markDirty() only if nodeDrag.moved
|
| Draw a connector |
startConnectDrag() from an edge handle (stops propagation so the node does not also start moving). onConnectDragMove() draws a .nm-temp-connector line. onConnectDragUp() hit-tests with findNodeAt() (icon box), refuses a self-loop and an identical connector in the same direction, then commitConnectorDrag()
|
| Label a connector | Double-click the hit path, the label rect or the text: promptConnectorLabel()
|
| Delete | A document keydown in bindCanvasEvents(): Delete / Backspace, ignored while typing or while a .nm-modal-overlay.active is open. deleteSelectedNode() also drops every connector touching the node |
| Add related objects |
fetchRelatedRows() calls get_related_objects.php; commitRelatedSelections() places the new nodes in a ring (radius 180px, growing past six) and adds one connector per ticked path - outgoing goes source to other, incoming and property paths go other to source |
There is no undo. Nothing in network-mapper.js records history; the way back from a wrong node is Delete. The phone layer (assets/js/mobile.js) changes none of this file - its place / move / delete gestures end by dispatching the same drop, mouse and keydown events these handlers already listen for. See the mobile page.
Read-only (historical) versions are enforced in the browser in three places: applyReadOnlyState() disables the buttons, the drop / drag / connect / delete handlers return early unless diagram.is_current, and markDirty() does nothing. The server refuses anyway.
markDirty() -> setStatus('unsaved') -> scheduleAutosave() (2s debounce, only if autosave is on)
save() -> POST save_diagram.php (everything) -> GET get_diagram.php -> re-render
-
save()sends the metadata, the paper settings, all six branding slots (so going back to "inherit" also saves), every node as{id, tempId, cmdb_object_id, x, y, size, icon_override}and every connector as{from_node_id, to_node_id, cmdb_relationship_id, label, line_style}. -
api/network-mapper/save_diagram.phpis a thin adapter. It turns each node'sid ?? tempIdinto aref, each connector's ends intofrom_ref/to_ref, and the flat branding keys intobranding.{header|footer}.{left|center|right}, then callsNetworkMapperService::saveDiagram(). - The service checks the diagram is a leaf (
requireLeaf()), updates the row, and in one transactionreplaceContents()deletes the old connectors and nodes and inserts the new ones, resolving each connector through the ref map. It is strict: an unknown CI, size, line style or icon key, a dangling ref or a self-link throws and the whole save rolls back. - Because the ids are new,
save()reloads the diagram, throws away the connector selection, and re-selects the node bycmdb_object_id. -
scheduleAutosave()waits whilenodeDragis set, because the reload would destroy the element being dragged. - Autosave on / off is the user preference
network_mapper_autosave(api/system/get_user_preference.php/set_user_preference.php). - The
beforeunloadwarning indiagram.phphas no getter to ask - it checks whether#saveStatushas the classnm-status-unsaved.
Every save, autosave included, ends with WorkflowEngine::emitCrud('network_diagram', 'updated', ...); create and delete emit created / deleted.
⚠️ Comments innetwork-mapper.jsstill talk aboutsave_diagram.php's "nodeIdMap" and "silently skipped" rows. That was the old endpoint. Today the map is the service's$refMap, and bad rows are an error, not skipped.
A version is a network_diagrams row whose parent_diagram_id points at the one before it. The leaf is the row nobody points at - list_diagrams.php finds them with WHERE d.id NOT IN (SELECT parent_diagram_id ...), and get_diagram.php returns is_current = (child_count === 0).
-
New version:
openNewVersionModal()saves first if dirty (the server copies what is stored, not what is on screen), suggests the next label withsuggestNextVersionLabel(), thencreateNewVersion()posts tocreate_version.phpand navigates to the new id.NetworkMapperService::createVersion()refuses a non-leaf parent - that is what keeps the chain linear - and copies the paper and branding settings, every node and every connector, mapping old node ids to new ones. -
Versions dropdown:
fetchVersionList()callslist_versions.php, which walks up to the root then down, and returns the chain ordered bycreated_datetime. Rows are real links. -
Delete (from the list page):
deleteDiagram()with$chain = false, so only a leaf can go; its parent becomes the leaf again. The REST API can delete a whole chain with?chain=true.
All api/network-mapper/, all check the session and requireModuleAccessJson('network-mapper'), all reply {success, ...}.
| Endpoint | Does |
|---|---|
list_diagrams.php |
Leaves only, with node_count, connector_count and version_count
|
get_diagram.php?id= |
One version, nodes hydrated from the CMDB |
list_versions.php?id= |
The chain, each with is_current, plus root_id
|
create_diagram.php |
NetworkMapperService::createDiagram() |
save_diagram.php |
NetworkMapperService::saveDiagram() (above) |
create_version.php |
NetworkMapperService::createVersion(), parent in parent_diagram_id
|
delete_diagram.php |
NetworkMapperService::deleteDiagram(), leaf only |
get_related_objects.php?object_id= |
One row per (object, path), kind = outgoing / incoming / property
|
The REST resource uses the same service for create / save / delete / version, and has its own handlers for the incremental node and connector routes and for suggestions. See REST API: Network Mapper.
-
Reading: names, classes, icons and the planned flag come from joins in
get_diagram.php(cmdb_objects,cmdb_classes,cmdb_icons). A node never stores them. -
Placing: the palette is
api/cmdb/get_classes.php(active classes only), the picker isapi/cmdb/get_objects.php. -
Related objects:
get_related_objects.phpreadscmdb_object_relationshipsboth ways - with the forwardverbboth ways (5.1.0). 🔴 TRAP: an incoming row's connector is drawn other → source, so its label must read from the other object: VM-A → HOST-A says runs on. It usedinverse_verb, which drew "VM-A hosts HOST-A" (write-up); the REST API's expand suggestions keep the inverse on purpose, reading from the object being expanded. It also readscmdb_object_properties.value_object_idfor object-ref properties. A relationship path carriesrelationship_id, which becomes the connector'scmdb_relationship_id; a property path has none and uses the property label as the connector label. -
The other direction: the CMDB blast radius can create a diagram - CMDB impact analysis - Developer Guide, section 10. That is why
diagram.phphonours?fit=1once on load.
- Pages use
requireModuleAccess('network-mapper'), endpointsrequireModuleAccessJson('network-mapper'). There are no finer capabilities in this module. - Diagrams themselves have no company. What is scoped is the CIs:
-
writes -
NetworkMapperService::validateObjectExists()refuses acmdb_object_idoutside the actor'scompanyScope, with the same "Unknown cmdb_object_id" message as a missing one, so it gives nothing away; -
placing -
get_objects.phpandget_related_objects.phpfilter by company (activeTenantFilter(), andanalystCanAccessCmdbObject()for the source object); -
reading -
get_diagram.phpdeliberately does not filter nodes by company. Its comment explains why: filtering would make nodes silently vanish from existing diagrams. The residual is a diagram drawn before CMDB companies existed. Giving diagrams a company is noted there as the first job of a future Network Mapper multi-tenancy slice.
-
writes -
- Write endpoints carry no explicit CSRF call; the central check in CSRF protection covers them.
There are no TRAP: comments in this module's code. The things that catch people out:
- 🔴 Node and connector ids change on every save. Never keep a node id across a save - in the browser, in a test or in an integration. The browser re-finds by
cmdb_object_id; the REST API's incremental endpoints exist precisely to give stable ids. - 🔴 A connector end is a number that may be negative. Compare with
nodeRef()andfindNodeByRef(), never onlyn.id.deleteSelectedNode()checks bothidandtempIdfor this reason. ⚠️ Icon overrides must exist incmdb_icons. The comment aboveopenIconPicker()says any key fromNM_ICONSworks because the column is free text. The column is, butNetworkMapperService::validateIcon()now refuses a key that is not incmdb_icons- add a new icon there as well as tonetwork-mapper-icons.js.⚠️ Export CSS names a class that does not exist. The.is-exportingrules indiagram.phphide.nm-node-edge-handle; the handles are.nm-edge-handle. Exports come out clean anyway, becausecaptureCanvas()clears the selection first and handles only show on hover or selection.- The picker hides objects already drawn, but the service accepts the same CI twice in a save (a connector naming a duplicated CI by
*_object_idis refused; byrefit works).