How do I generate a network diagram from LLDP output?
Collect neighbors with the detail command, normalize each link into a four-field record, deduplicate the two reports of every cable, then print Mermaid. Parsing is where this goes wrong, and it gets most of the space below.
- Parse `show lldp neighbors detail` into structured links without a vendor library.
- Recognize the three parsing failures that produce a diagram that looks right and is wrong.
- Turn a normalized link list into a rendered topology.
Collect show lldp neighbors detail from every device, parse each entry into
four fields, deduplicate the links, then print Mermaid. Printing the diagram
takes about twenty lines. Parsing is where diagrams go wrong, and that is the
focus for most of what follows.
If you want to see the whole path end to end first, the diagrams as code reference covers the Mermaid side, and the network diagram generator runs the parse in your browser against your own output, with no account.
Use the detail command, not the summary table
Most examples of this start from show lldp neighbors, because the summary is
compact and looks like a table you could split on whitespace. It is the wrong
source.
sw-acc-01#show lldp neighbors
Device ID Local Intf Hold-time Capability Port ID
sw-core-datacenter Gi1/0/48 120 B,R Gi1/0/1
sw-core-datacent Gi1/0/47 120 B,R Gi1/0/1
Those are two different core switches. The Device ID column is fixed width, so a long system name is cut to fit, and there is no marker saying it was cut. Two devices whose names share a prefix truncate to strings that differ only by the tail you no longer have. A generator that trusts this column produces one node where there are two, or two nodes that are really one, and the diagram renders cleanly either way.
The detail output carries the full name:
sw-acc-01#show lldp neighbors detail
------------------------------------------------
Local Intf: Gi1/0/48
Chassis id: 0026.0b1f.4a80
Port id: Gi1/0/1
Port Description: uplink to access floor 3
System Name: sw-core-datacenter-a
System Description:
Cisco IOS Software, Catalyst L3 Switch Software
Time remaining: 97 seconds
System Capabilities: B,R
Enabled Capabilities: B,R
Management Addresses:
IP: 10.10.0.11
------------------------------------------------
It is more text to parse, but the extra detail is worth it. If you are
collecting CDP instead, use show cdp neighbors detail for the same reason.
The shape everything else depends on
Pick the record shape before you write a parser, because every later step reads it and nothing else:
{
"local": "sw-acc-01", # the device you polled
"local_intf": "Gi1/0/48", # its port
"remote": "sw-core-datacenter-a", # the neighbor's system name
"remote_intf": "Gi1/0/1", # the neighbor's port
}
Four fields. If a collection method cannot fill all four, that is a decision to make at parse time, not something to discover while rendering.
Parsing the detail output
The detail output is a series of blocks separated by dashed lines, each block a
set of Key: value lines. That is enough structure to parse without a vendor
library:
import re
FIELDS = {
"Local Intf": "local_intf",
"Port id": "remote_intf",
"Port Description": "remote_desc",
"System Name": "remote",
}
def parse_lldp_detail(text, local_device):
"""Turn one device's `show lldp neighbors detail` into link records."""
links = []
entry = {}
for line in text.splitlines():
line = line.strip()
# A run of dashes ends the current neighbor block.
if set(line) == {"-"} and line:
if entry:
links.append(entry)
entry = {}
continue
key, sep, value = line.partition(":")
if not sep:
continue
field = FIELDS.get(key.strip())
if field:
entry[field] = value.strip()
if entry:
links.append(entry)
return [normalize(e, local_device) for e in links if "remote" in e]
Two details in there are doing real work. Splitting on the first colon only,
with partition, keeps a System Description containing a colon from
corrupting the block. And requiring remote before keeping the record throws
away the partial blocks that appear when output is truncated by a terminal
length limit.
The three failures worth handling
The parser above gets you structured data. These three are what turn structured data into a wrong diagram.
A port id is not always an interface name
LLDP port ids carry a subtype. Switches usually send the interface name. Phones, access points, and some server NICs send a MAC address, because that is also legal:
Local Intf: Gi1/0/12
Port id: 0026.0b1f.9c04
Port Description: eth0
System Name: ap-floor3-north
A MAC on the edge label tells the reader nothing. Fall back to the port description, and if that is missing too, label the edge with the local interface alone:
def remote_interface(entry):
"""Prefer a real interface name; a MAC on a diagram helps nobody."""
port_id = entry.get("remote_intf", "")
if re.fullmatch(r"[0-9a-f]{4}\.[0-9a-f]{4}\.[0-9a-f]{4}", port_id, re.I):
return entry.get("remote_desc") or ""
return port_id
Every cable is reported twice
Poll two switches that are connected to each other and you get two records for one cable, one from each end, with local and remote swapped. A generator that does not notice draws both, and the two edges land on top of each other with different labels.
Deduplicate on the unordered pair of endpoints:
def link_key(link):
"""Same cable from either end produces the same key."""
a = (link["local"], link["local_intf"])
b = (link["remote"], link["remote_intf"])
return tuple(sorted([a, b]))
def dedupe(links):
seen = {}
for link in links:
seen.setdefault(link_key(link), link)
return list(seen.values())
Sorting the two endpoint tuples is what makes the key direction free. Keying on the device pair alone is not enough, because two switches connected by four links in a port channel are four cables, and you want all four.
Names arrive in more than one form
CDP commonly reports a fully qualified name, sw-core-01.campus.example.com,
while LLDP on the same device reports sw-core-01. Mix the two collections and
one switch becomes two nodes sitting next to each other.
Normalize once, at parse time:
def normalize(entry, local_device):
remote = entry["remote"].split(".")[0].lower()
return {
"local": local_device.split(".")[0].lower(),
"local_intf": entry.get("local_intf", ""),
"remote": remote,
"remote_intf": remote_interface(entry),
}
Stripping at the first dot is right for a flat naming scheme and wrong if your hostnames contain dots on purpose. Check yours before copying this.
Printing the diagram
With clean records, the generator is short. Declare each device once in first-seen order, then one edge per link:
def node_id(hostname):
return hostname.replace(".", "_").replace("-", "_")
def to_mermaid(links):
lines = ["flowchart TD"]
seen = []
for link in links:
for host in (link["local"], link["remote"]):
if host not in seen:
seen.append(host)
lines.append(f' {node_id(host)}["{host}"]')
for link in links:
label = f'{link["local_intf"]} to {link["remote_intf"]}'
lines.append(
f' {node_id(link["local"])} ---|"{label}"| '
f'{node_id(link["remote"])}'
)
return "\n".join(lines)
First-seen order is fine for a diagram you look at. If you are going to commit this file and compare it against a fresh run, sort the devices and the links instead, or the file changes on every collection and the comparison stops meaning anything. That is covered in keeping diagrams from going stale.
Run it over a two-closet campus and you get this:
View diagram source - it's just text (Mermaid). Diagrams-as-code is how modern network docs work; the flagship course has a free module on it.
flowchart TD
sw_acc_01["sw-acc-01"]
sw_core_datacenter_a["sw-core-datacenter-a"]
sw_core_datacenter_b["sw-core-datacenter-b"]
ap_floor3_north["ap-floor3-north"]
rtr_edge_01["rtr-edge-01"]
sw_acc_01 ---|"Gi1/0/48 to Gi1/0/1"| sw_core_datacenter_a
sw_acc_01 ---|"Gi1/0/47 to Gi1/0/1"| sw_core_datacenter_b
sw_acc_01 ---|"Gi1/0/12 to eth0"| ap_floor3_north
sw_core_datacenter_a ---|"Te1/1/1 to Gi0/0/0"| rtr_edge_01
sw_core_datacenter_a ---|"Po1 to Po1"| sw_core_datacenter_bBoth core switches are present and distinct, which is the part the summary table would have cost you.
Deciding what belongs in the diagram
Neighbor data includes everything that speaks LLDP, so an unfiltered run puts phones, access points, hypervisor uplinks, and the occasional printer into your topology. The data is accurate, but for an infrastructure view it usually is not the diagram you wanted.
Filter against your inventory when you want infrastructure:
INFRASTRUCTURE = {"sw-", "rtr-", "fw-"}
def is_infrastructure(name):
return any(name.startswith(p) for p in INFRASTRUCTURE)
infra_links = [
l for l in links
if is_infrastructure(l["local"]) and is_infrastructure(l["remote"])
]
Prefix matching is the cheap version. If you have a real source of truth, match against it instead, and treat a neighbor that is not in inventory as something worth reporting rather than something to drop.
What to do with the output
Write it to topology.mmd and commit it in the repo that holds the configs it
describes. From there the diagram changes in the same commit as the change, and
a deleted link is a deleted line in a pull request.
Keeping that file honest over time, so it is regenerated rather than remembered, is its own problem. That is covered in how to keep network diagrams from going stale.
The graded version of this, with the four generator functions checked against a test suite, is free with an account in the Mermaid lesson. The collection side, which is where the neighbor output in this note comes from, is Discover and document the network.