TRANSLATION

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.

Reading this, you will:
  • 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:

Generated from parsed neighbor detail
Rendering diagram…
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_b
Generated from parsed neighbor detail

Both 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.