Topology (Read)
List Devices
GET /api/v1/devices?area_id={areaID}&limit=100&offset=0
List all devices in a specific area. Supports pagination.
Query parameters:
area_id (required) — UUID of the area
limit (optional) — Max items to return (default 100, max 1000)
offset (optional) — Items to skip (default 0)
Response: 200 OK — Paginated envelope
{
"data": [
{
"id": "uuid",
"router_id": "10.0.0.1",
"hostname": "router1",
"dns_name": "router1.example.com",
"vendor": "industrial",
"device_type": "router",
"is_abr": false,
"is_ospf_abr": false,
"is_isis_l1l2": false,
"is_asbr": false,
"is_collector": false,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
],
"total": 1,
"limit": 100,
"offset": 0
}
Error: 400 Bad Request — Missing area_id
Export Devices
GET /api/v1/devices/export?format=csv|json&area_id={areaID}
GET /api/v1/devices/export?format=csv|json&pi_id={piID}
GET /api/v1/devices/export?format=csv|json&rd_id={rdID}
GET /api/v1/devices/export?format=csv|json&network_id={networkID}
Export all devices in the given scope as CSV or JSON. Devices are deduplicated across areas. Each row includes area names (comma-separated) and interface count.
Query parameters: Standard scope parameters (area_id, pi_id, rd_id, network_id) plus format (csv or json, default json).
CSV columns: ID, Router ID, Hostname, DNS Name, Vendor, Device Type, Model, Platform, Software Version, SysObject ID, ABR, ASBR, Collector, First Seen, Last Seen, Areas, Interfaces.
JSON response: Array of DeviceExportRow objects:
[{
"id": "uuid",
"router_id": "10.0.0.1",
"hostname": "router-1",
"dns_name": "router-1.example.com",
"vendor": "industrial",
"device_type": "router",
"model": "ISR4431",
"platform": "IOS-XE",
"software_version": "17.06.03a",
"sys_object_id": "1.3.6.1.4.1.9.1.2658",
"is_abr": true,
"is_ospf_abr": true,
"is_isis_l1l2": false,
"is_asbr": false,
"is_collector": false,
"first_seen": "2026-01-15T10:30:00Z",
"last_seen": "2026-02-24T12:00:00Z",
"areas": "0.0.0.0, 0.0.0.1",
"interface_count": 12
}]
Export Visio
POST /api/v1/export/visio?area_id={areaID}
POST /api/v1/export/visio?pi_id={piID}
POST /api/v1/export/visio?rd_id={rdID}
POST /api/v1/export/visio?network_id={networkID}
Generate a Microsoft Visio (.vsdx) topology diagram for the given scope.
Request body:
{
"positions": { "device-uuid": { "x": 100, "y": 200 } },
"exclude_device_ids": ["device-uuid-1"],
"icon_pack": "default",
"label_mode": "hostname",
"icon_defaults": { "router": { "packId": "default", "iconId": "router" } },
"device_icon_overrides": { "device-uuid": { "packId": "uuid-pack-id", "iconId": "uuid-icon-id" } },
"render_model": {
"nodes": [ { "id": "device-uuid", "x": 100, "y": 200, "w": 60, "h": 60, "label": "R1", "classes": "" } ],
"edges": [ { "id": "e1", "source": "uuidA", "target": "uuidB", "sx": 1, "sy": 2, "tx": 3, "ty": 4,
"control_points": [ { "x": 2, "y": 3 } ], "color": "#4A90D9", "width": 2, "line_style": "solid",
"label_color": "#DCE0E6", "label_bg": "#1E293B", "label_size": 6,
"labels": [ { "text": "10", "slot": "center" } ] } ],
"hulls": [ { "area_label": "0.0.0.0", "color": "#3B82F6", "polygon": [ { "x": 0, "y": 0 } ] } ]
}
}
| Field |
Type |
Required |
Description |
positions |
map[string]{x,y} |
No |
Cytoscape canvas coordinates; overrides saved layout |
exclude_device_ids |
string[] |
No |
Device IDs to omit (hidden nodes) |
icon_pack |
string |
No |
"default" or "industrial" |
label_mode |
string |
No |
"hostname", "dns", "router_id", or "hostname_ip" |
icon_defaults |
map[string]IconRef |
No |
Per-device-type icon defaults (e.g. {"router": {"packId":"industrial","iconId":"router"}}). For custom packs, packId is the pack UUID and iconId is the icon UUID. |
device_icon_overrides |
map[string]IconRef |
No |
Per-device icon overrides keyed by device UUID. Same IconRef format as icon_defaults. |
render_model |
object |
No |
High-fidelity capture of the live canvas in Cytoscape model coordinates: per-edge endpoints + bezier control_points, resolved line/label styles and label slots; per-node placement + classes; and area-boundary polygons. When present, the exporter reproduces the on-screen curves, label chips and area hulls 1:1 instead of re-deriving geometry. Absent → legacy re-derivation from positions. Captured by the frontend (web/src/components/topology/captureModel.ts). |
Response: 200 OK with Content-Type: application/vnd.ms-visio.drawing and Content-Disposition: attachment.
Error: 400 Bad Request (no areas in scope), 500 Internal Server Error
Note: PNG and SVG exports are client-side only (Cytoscape canvas rendering in the browser). There are no REST endpoints for PNG/SVG export.
Get Device
GET /api/v1/devices/{deviceID}
Get a single device by ID.
Response: 200 OK (single device object)
Error: 404 Not Found
Get Device RIB
GET /api/v1/devices/{deviceID}/rib
GET /api/v1/devices/{deviceID}/rib?area_id={areaID}
GET /api/v1/devices/{deviceID}/rib?explain=true
GET /api/v1/devices/{deviceID}/rib?at={RFC3339}
Convenience wrapper around GET /topology/rib that auto-resolves area scope from the device's area memberships. For multi-area devices (ABRs), all areas are merged to give the complete routing table perspective. An optional area_id parameter restricts to a single area.
When an EIGRP area is in scope, the device's observed EIGRP topology-table routes are merged in as type D (internal) / D EX (redistributed) — connected-origin entries as C — joining the same AD-based best-route selection as the SPF-computed candidates (EIGRP itself is never fed to SPF). Their metric is the feasible distance the router reported and via_area shows the EIGRP AS ("AS 100").
Supports the same explain and at parameters as GET /topology/rib.
At a historical time, EIGRP rows come only from the per-device snapshot covering
T. Coverage metadata distinguishes a proven empty table from missing evidence;
the handler never borrows live eigrp_route rows.
next_hops are expressed in the address family of each route's destination: an IPv4 route lists the next-hop router's IPv4 router-id, while an IPv6 route lists the next-hop router's IPv6 loopback (its lowest-cost /128 stub), falling back to the IPv4 router-id when the next hop advertises no IPv6 loopback.
Response: Same as GET /topology/rib — 200 OK with RIBResponse.
Error responses:
404 Not Found — Device not found in topology
500 Internal Server Error — Database error
List Links
GET /api/v1/links?area_id={areaID}&limit=100&offset=0
List all links in a specific area. Supports pagination.
Query parameters:
area_id (required) — UUID of the area
limit (optional) — Max items to return (default 100, max 1000)
offset (optional) — Items to skip (default 0)
Response: 200 OK — Paginated envelope
{
"data": [
{
"id": "uuid",
"source_device_id": "uuid",
"destination_device_id": "uuid",
"source_interface_id": "uuid",
"destination_interface_id": "uuid",
"link_type": "point-to-point",
"state": "up",
"cost_forward": 10,
"cost_reverse": 10,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
],
"total": 1,
"limit": 100,
"offset": 0
}
Error: 400 Bad Request — Missing area_id
Get Link Detail
GET /api/v1/links/{linkID}
Return the full detail record for a single link, including source/target device info and bound interface details. Used by the topology canvas link detail panel. Requires any authenticated role.
Response: 200 OK
{
"link": {
"id": "uuid",
"source_device_id": "uuid",
"destination_device_id": "uuid",
"cost_forward": 10,
"cost_reverse": 10,
"link_type": "p2p",
"state": "up",
"area_id": "uuid",
"reported_by_collector_id": "collector-config-uuid",
"first_seen": "2026-06-01T10:00:00Z",
"last_seen": "2026-06-11T10:00:00Z"
},
"source": { "id": "uuid", "router_id": "10.0.0.1", "hostname": "router1", "dns_name": "router1.example.com" },
"target": { "id": "uuid", "router_id": "10.0.0.2", "hostname": "router2" },
"source_interface": { "id": "uuid", "if_name": "GigabitEthernet0/0", "ip_address": "10.0.12.1", "mask": "255.255.255.252" },
"target_interface": null
}
reported_by_collector_id is the collector (config UUID) that last reported the link — per-collector attribution; omitted for unattributed rows. target/source_interface/target_interface are null when unknown.
Error responses:
401 Unauthorized — Missing or invalid access token
404 Not Found — Link not found
500 Internal Server Error — Database error
Get Topology Graph
GET /api/v1/topology/graph?area_id={areaID}
GET /api/v1/topology/graph?pi_id={piID}
GET /api/v1/topology/graph?rd_id={rdID}
Returns topology graph in Cytoscape.js elements format (nodes and edges).
Query parameters (scope — mutually exclusive, one required):
area_id — Graph for a single area
pi_id — Aggregated graph for all areas in a protocol instance
rd_id — Aggregated graph for all areas in a routing domain
network_id — Aggregated graph for all areas in a network
Optional:
at — RFC 3339 timestamp (e.g. 2026-02-22T10:00:00Z). When present, returns the historical topology from the nearest snapshot at or before the given time instead of live data. Multi-area scopes merge one snapshot per area with devices deduplicated by ID (each area snapshot embeds its full member list, so an ABR or dual-stack device would otherwise appear once per protocol instance × area) — the response carries exactly one node element per device, same contract as live. Snapshot interfaces missing if_index/if_name (captured before SNMP enrichment) are backfilled from the current interface row by its stable id, so dual-stack links still merge into one edge in time-travel. The response ETag folds in a render-version AND the areas' newest interface updated_at, so both graph-logic changes and SNMP enrichment (interface rows / link binding — the dual-stack merge inputs, which never touch snapshots) invalidate cached client responses (no manual refresh needed). Applies to live and historical responses alike.
Response: 200 OK
[
{
"group": "nodes",
"data": {
"id": "uuid",
"router_id": "10.0.0.1",
"label": "router1",
"hostname": "router1",
"dns_name": "router1.example.com",
"management_ip": "10.0.0.1",
"management_ip_source": "loopback",
"vendor": "industrial",
"device_type": "router",
"role": "internal",
"is_abr": false,
"is_ospf_abr": false,
"is_isis_l1l2": false,
"is_asbr": false,
"is_collector": false,
"isolated": false,
"areas": ["0.0.0.0"],
"area_info": [
{ "id": "area-uuid", "label": "0.0.0.0", "protocol": "ospfv2", "area_type": "normal", "role": "internal", "pid": "1", "protocol_instance_id": "pi-uuid", "address_family": "ipv4" }
]
},
"classes": "internal"
},
{
"group": "edges",
"data": {
"id": "uuid",
"source": "device-uuid-1",
"target": "device-uuid-2",
"cost_forward": 10,
"cost_reverse": 10,
"cost": 10,
"link_type": "point-to-point",
"state": "up",
"source_ip": "10.0.12.1",
"target_ip": "10.0.12.2",
"source_mask": "255.255.255.252",
"target_mask": "255.255.255.252",
"source_ip_v6": "2001:db8::1",
"target_ip_v6": "2001:db8::2",
"source_mask_v6": "ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffc",
"target_mask_v6": "ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffc",
"reported_by_collector_id": "collector-config-uuid",
"merged_link_ids": ["link-uuid-v2", "link-uuid-v3"]
},
"classes": "up"
}
]
Edge attribution:
reported_by_collector_id — Collector (config UUID) that last reported the primary link of this edge (per-collector attribution). Omitted for unattributed links. When an area is partitioned and two recorders report disjoint islands, each island's edges carry their own recorder's ID — the field the per-island tint keys on.
Edge dual-stack merging:
- When OSPFv2 and OSPFv3 links connect the same device pair in the same area, they are merged into a single Cytoscape edge. The edge carries both IPv4 (
source_ip/target_ip) and IPv6 (source_ip_v6/target_ip_v6) addresses. The merged_link_ids array lists the individual link UUIDs. Single-protocol edges omit *_v6 fields and merged_link_ids.
Per-protocol metadata (multi-protocol merged edges):
merged_protocols[] — one entry per member link: protocol, area_label, area_id, pid, address_family, cost_forward, cost_reverse, state, link_id. pid is the protocol-instance PID — for EIGRP the AS number, which together with address_family is what the UI renders instead of the synthetic 0.0.0.0 area label (AS 100 (v4)), keeping multiple EIGRP rows on one link distinguishable.
- Single-protocol EIGRP edges carry
pid + address_family at the edge level for the same reason.
Edge state aggregation (merged edges):
- A merged edge's
state is "up" while any member link is up (the edge still forwards), and "down" only when every member is down.
- When some-but-not-all members are down, the class
degraded is added alongside up (per-member states are in merged_protocols[].state). The frontend renders degraded edges dashed in their area color — versus dashed red for fully down.
- Aggregated
cost_forward/cost_reverse take the minimum over up members only while any member is up; a downed member's last-known cost never wins the display. When all members are down, all members contribute (display is "∞" anyway).
Edge cost display logic:
- If link state is
down: cost displays as "∞" (infinity symbol)
- If forward and reverse costs differ:
"→10 / ←20" and class asymmetric is added
- Otherwise: single numeric cost
Node data fields:
management_ip — Selected reachable SNMP poll address (host form), decoupled from router_id (which per RFC is identity-only and need not be reachable). Empty for pure-protocol devices with no SNMP target. Mirrors device.management_ip. Also present on the GET /devices/{deviceID} response.
management_ip_source — Ladder rung that produced management_ip (loopback/stub/interface/router_id/snmp_ipv6/probe/manual). router_id indicates an unverified fallback (no address has answered SNMP yet). Empty when management_ip is empty.
areas — Array of area labels (dotted-quad strings), deduplicated by label. Used for ABR detection (length > 1).
area_info — Array of {id, label, protocol, area_type, role, pid, protocol_instance_id, address_family} objects, deduplicated by area UUID. Each entry carries the area UUID, dotted-quad label, parent protocol ("ospfv2"/"ospfv3"/"isis"), OSPF area type ("normal"/"stub"/"nssa"/…), the device's role in that area ("internal"/"abr"/"asbr"), and the owning protocol instance's process ID, UUID, and address family ("ipv4"/"ipv6"). Lets the UI group a node's areas by OSPF process / protocol instance (the same router can sit in different areas per process) and disambiguate mixed v2/v3 areas on ABR devices. area_type/role/pid/protocol_instance_id/address_family are omitted on entries sourced from snapshots saved before this enrichment.
is_overloaded — (IS-IS nodes only) true when the device's own LSP sets the overload bit. Omitted for OSPF nodes.
area_addresses — (IS-IS nodes only) Array of NET/area addresses (e.g. ["49.0001"]) advertised by the device. Omitted for OSPF nodes and when none are known.
protocols_supported — (IS-IS nodes only) IS-IS TLV 129 NLPIDs (204=IPv4, 142=IPv6, 129=OSI/CLNP); the UI decodes these into address-family labels. Omitted for OSPF nodes and when none advertised.
Node classes:
"internal", "abr", "asbr" — based on OSPF role
"collector" — if device is the collector
"isolated" — if all links are down
Edge classes:
"up" / "down" — aggregated link state (see Edge state aggregation)
"degraded" — merged edge with some-but-not-all member links down (always alongside up)
"asymmetric" — forward and reverse costs differ
Error responses:
400 Bad Request — Missing or invalid query parameter
500 Internal Server Error — Database error
Get Topology Graph Summary
GET /api/v1/topology/graph/summary?area_id={areaID}
GET /api/v1/topology/graph/summary?pi_id={piID}
GET /api/v1/topology/graph/summary?rd_id={rdID}
GET /api/v1/topology/graph/summary?network_id={networkID}
Returns aggregate counts without the full Cytoscape elements payload. Useful for dashboards, health checks, and lightweight polling. Counts exclude GRE recorder devices (is_collector) and their adjacency links, matching the rendered topology (recorders are hidden on the canvas).
Query parameters: Same scope resolution as GET /topology/graph.
Response: 200 OK
{
"devices": 42,
"links": 87,
"areas": 2,
"protocol_instances": 1,
"per_area": [
{ "area_id": "uuid", "area_label": "0.0.0.0", "devices": 30, "links": 55 },
{ "area_id": "uuid", "area_label": "0.0.0.1", "devices": 18, "links": 32 }
]
}
Error responses:
400 Bad Request — Missing or invalid query parameter
500 Internal Server Error — Database error
Get Area Clouds
GET /api/v1/topology/area-clouds?pi_id={piID}
GET /api/v1/topology/area-clouds?rd_id={rdID}
GET /api/v1/topology/area-clouds?network_id={networkID}
Returns aggregated area cloud data for the large-scale topology view. Each area is represented as a "cloud" with device/link counts, health status, and alert counts. ABRs connecting areas are listed separately, and inter-area edges show which areas share ABRs.
Query parameters (scope — mutually exclusive, one required):
pi_id — Protocol instance UUID (most specific)
rd_id — Routing domain UUID (expands to all PIs in domain)
network_id — Network UUID (expands to all PIs in network)
Optional:
include_empty — "1" to include areas with zero devices/links (default "0")
at — RFC 3339 timestamp for historical data (time travel). Device/link counts, ABRs, and inter-area edges are reconstructed from each area's nearest topology snapshot at or before the timestamp; alert/incident counts use their fired/first-event → resolved windows. An area without a snapshot at that time counts as empty. Rejected with 400 when it predates the snapshot retention window. (Until 2026-07-29 this parameter silently returned live data.)
Response: 200 OK
{
"areas": [
{
"area_id": "uuid",
"area_label": "0.0.0.0",
"area_type": "normal",
"protocol": "ospfv2",
"address_family": "ipv4",
"protocol_instance_id": "uuid",
"pid": 1,
"device_count": 42,
"link_count_total": 87,
"link_count_down": 2,
"abr_count": 3,
"active_alerts": { "info": 0, "warning": 2, "critical": 0 },
"active_incidents": 0,
"health": "warning",
"flags": { "lsdb_stale": false }
}
],
"abrs": [
{
"device_id": "uuid",
"router_id": "10.0.0.1",
"hostname": "abr1",
"dns_name": "abr1.example.com",
"system_id": null,
"protocol_instance_ids": ["uuid1", "uuid2"],
"area_ids": ["uuid1", "uuid2"],
"is_ospf_abr": true,
"is_isis_l1l2": false,
"is_asbr": false,
"flags": { "non_standard": false }
}
],
"edges": [
{
"from_area_id": "uuid1",
"to_area_id": "uuid2",
"abr_device_ids": ["uuid1", "uuid2", "uuid3"],
"abr_count": 3,
"kind": "physical"
}
]
}
Field details:
area_type: "normal", "stub", "nssa", "totally_stubby"
health: "healthy", "warning", "critical" (based on alert severity and link state)
kind: "physical" (ABR-connected) or "virtual" (virtual link)
flags.non_standard: True if ABR uses RFC 3509 non-backbone-connected ABR behavior
ETag caching: Live responses include an ETag header computed from snapshot hashes and alert counts. Clients can use If-None-Match to receive 304 Not Modified when data hasn't changed.
Error responses:
400 Bad Request — Missing scope parameter, unsupported protocol (only ospfv2, ospfv3, isis), or invalid at timestamp
404 Not Found — Routing domain or network not found
500 Internal Server Error — Database error
Get Snapshot Timeline
GET /api/v1/topology/snapshots/timeline?area_id={areaID}
GET /api/v1/topology/snapshots/timeline?pi_id={piID}
GET /api/v1/topology/snapshots/timeline?rd_id={rdID}
GET /api/v1/topology/snapshots/timeline?network_id={networkID}
Returns distinct snapshot timestamps for the selected scope, ordered descending (newest first). Used by the time-travel scrubber to show available historical points.
Query parameters (scope — mutually exclusive, one required):
area_id, pi_id, rd_id, or network_id — same scope params as topology graph
Optional:
from — RFC 3339 timestamp, default 7 days ago
to — RFC 3339 timestamp, default now
limit — Maximum timestamps to return, default 500 (max 5000)
Response: 200 OK
{
"timestamps": [
"2026-02-22T10:15:00Z",
"2026-02-22T10:10:00Z",
"2026-02-22T10:05:00Z"
]
}
Error responses:
400 Bad Request — Missing scope parameter
500 Internal Server Error — Database error
Get Historical Stale Areas
GET /api/v1/topology/snapshots/stale-areas?at={RFC3339}&area_id={areaID}
GET /api/v1/topology/snapshots/stale-areas?at={RFC3339}&pi_id={piID}
GET /api/v1/topology/snapshots/stale-areas?at={RFC3339}&rd_id={rdID}
GET /api/v1/topology/snapshots/stale-areas?at={RFC3339}&network_id={networkID}
Returns area UUIDs (per protocol instance) that were stale at a given historical time. Used by the time-travel frontend to dim areas that had no active data collection at the selected point in time. UUIDs (not dotted-quad labels) so the frontend distinguishes the OSPFv2 and OSPFv3 instances of the same area label — a shared label would dash healthy v2 links when only the v3 instance is stale.
Detection method: Event-sourced, with snapshot history as the recovery signal. The API takes each area's most recent area_stale/area_recovered event at or before the requested time. An area is reported stale only if that event is area_stale and no topology_snapshot row exists after it (up to the requested time) — a later snapshot proves the collector resumed reporting, even when the matching area_recovered event was never persisted (recovery events are debounced and under-recorded). Because topology_snapshot rows are hash-deduplicated (written only on a topology change), a healthy but stable area can go hours without a new row; this rule handles that correctly without any proximity/grace window, and is derived entirely from immutable event/snapshot history so live retention never alters it.
Query parameters (scope -- mutually exclusive, one required):
area_id, area_ids, pi_id, rd_id, or network_id -- same scope params as topology graph
Required:
at -- RFC 3339 timestamp (e.g. 2026-02-22T10:00:00Z)
Response: 200 OK
{
"stale_areas": ["2f61f510-7cb8-4f43-9772-89616078ab9e"]
}
An empty array means all areas were healthy at the requested time. Values are area UUIDs (per protocol instance), matching the area_id field in WebSocket area_stale / area_recovered diff events and the area_id / merged_protocols[].area_id data attributes on Cytoscape elements.
Error responses:
400 Bad Request -- Missing or invalid at parameter, or missing scope parameter
500 Internal Server Error -- Database error
Compute Shortest Path
GET /api/v1/topology/path?pi_id={piID}&source={sourceDeviceID}&destination={destDeviceID}
GET /api/v1/topology/path?pi_id={piID}&source={sourceDeviceID}&prefix=0.0.0.0/0
Computes the shortest path using area-aware SPF per RFC 2328 §16.1-16.4 (OSPF) or ISO 10589 (IS-IS). Supports two modes:
- Device-to-device: Returns forward and reverse paths, detecting asymmetric routing.
- Path-to-prefix: Prices every candidate the way the source router itself would: intra-area stub AND transit-link prefixes per RFC 2328 §16.1 (transit subnets are derived from the interface rows — dist to the attached router + its interface cost), Type-3 summaries strictly per §16.2 (only in areas the source is attached to, ABR distance within the carrying area, unreachable ⇒ skip), externals per §16.4, and BGP with the AD weighed at the installing router (a best path exported by another device reaches the source over the iBGP mesh — AD 200, not the exporter's eBGP 20). A summary the source defers per §16 step (3) can still answer a prefix nothing else covers, but then always carries a
caveat + path_model marking it approximate.
The algorithm respects routing rules:
- Intra-area routes (O) are always preferred over inter-area (O IA), regardless of metric.
- Inter-area traffic transits the backbone (area 0.0.0.0 / level-2) through ABRs via a 3-segment path.
- E1 routes are preferred over E2 per RFC 2328 Section 16.4.
- ECMP: equal-cost multi-paths are enumerated (up to 8).
- BGP fallback (Step 4): When no IGP route matches (Steps 1-3), queries
bgp_best_path using longest-prefix match (prefix >>= $1::inet). Returns path to next-hop device, or a partial result with warning if next-hop is unresolved. When at is set (time-travel mode) this step evaluates against BGP history: candidates come from bgp_best_path_history as of at, with honest annotations when the record is imperfect — a warning when no route was recorded at at (history may not cover the period) and a warning when the exit device's BGP session was in a monitoring gap at at.
- AD-based selection: All four steps (intra-area, inter-area, external, BGP) collect candidates. The winner is selected by lowest administrative distance (configurable via
routing.ad.* system settings). Default: eBGP=20, OSPF=110, IS-IS=115, iBGP=200. Connected routes (AD 0) always win.
- Response includes
source ("ospf", "ospfv3", "isis", "bgp"), ad_value (winning AD), and bgp_info (AS_PATH, communities, LP, MED, ECMP) when source is BGP.
- AD is per-candidate and protocol-correct: every IGP candidate derives its AD from its source area's protocol (
routing.ad.ospfv2/ospfv3/isis/eigrp — consistent with the RIB view by pinned test; EIGRP additionally splits internal 90 vs redistributed 170 via routing.ad.eigrp_external, driven per-route by cEigrpRouteOriginType), the intra-over-inter preference only suppresses an inter-area candidate whose AD is not strictly lower (so an OSPF inter-area route at 110 beats an IS-IS intra route at 115), externals are selected per AD group (cross-protocol external races are decided by AD, not by comparing E2 metrics across protocols), and BGP candidates carry the source session's REAL peer type joined from bgp_peer (live) or bgp_peer_history (as-of-T) — the AS_PATH heuristic (single-element path ⇒ iBGP) is only the fallback when no source peer resolves.
- Dual-IGP device pairs (endpoints sharing both an OSPF area and an IS-IS level): the lower-AD protocol's path wins regardless of metric; when the losing protocol had a cheaper path the result carries a
caveat naming it. Equal-AD areas (OSPFv2+v3 dual-stack) keep pure metric selection.
Query parameters (scope — mutually exclusive, one required):
area_id — UUID of the area (intra-area only)
pi_id — UUID of the protocol instance (multi-area path)
rd_id — UUID of the routing domain (multi-area path)
network_id — UUID of the network (multi-area path)
Required:
source — UUID of the source device
Destination (one required, mutually exclusive):
destination — UUID of the destination device
prefix — CIDR prefix (e.g. 0.0.0.0/0, 192.0.2.0/24) for path-to-prefix mode
Optional:
at — RFC 3339 timestamp (e.g. 2026-02-22T10:00:00Z). When present, topology, BGP and EIGRP forwarding evidence are all selected at the same T. EIGRP uses complete per-device route snapshots through one memoized request resolver; gaps make the affected segment opaque with eigrp_history, never a live fallback or silent alternative-border win. Live-only L2/port annotations remain explicitly labelled. Future timestamps are rejected.
explain — Boolean query parameter. When true, each PathInfo object includes a explanation: RouteStep[] array detailing the route computation reasoning (e.g., intra-area preference, inter-area backbone transit, external route selection).
protocol — Filter areas to a single protocol in multi-protocol environments (ospfv2, ospfv3, or isis). When set, only areas belonging to the specified protocol are used for path computation. The response includes protocol in the PathResult.
af — Address family selector: clns, ipv4, or ipv6. Default, in order of authority: (1) an explicitly supplied source_addr/dest_addr names the family outright — it already steers area selection, and an IP address rules out CLNS; (2) clns for IS-IS protocol instances; (3) otherwise derived from the scoped areas' address family — ipv6 when the scope holds at least one IPv6 area and no IPv4 one, ipv4 otherwise (including RFC 5838 OSPFv3 IPv4-AF instances, genuinely mixed scopes, and a failed lookup). An IS-IS level's multi address family (one LSDB serves both) is compatible with either and casts no vote, so a carrier level in scope no longer mislabels an otherwise pure-IPv6 selection as ipv4. For af=ipv6: hop in_interface/out_interface addresses show the port's IPv6 (dual-stack IS-IS/EIGRP rows carry it in ipv6_address; a port without IPv6 shows no address), and the cross-domain stitch matches BGP evidence in-family — border selection runs against an IPv6 target address and only IPv6 eBGP sessions count as edges; with no IPv6 evidence between the ASes the domain verdict stands without stitched hops and the explanation names the reason. When af=clns: hops use system_id instead of router_id, include nets (full NET addresses), and interface IPs are omitted. af=clns is incompatible with prefix (returns 400). The response includes address_family in the PathResult.
Hop-by-hop forwarding chain (device mode): forward_path and reverse_path carry the path the packet actually takes — every router's OWN routing-table decision toward the resolved destination address — rather than the source's least-cost corridor. Real IP forwarding is hop-by-hop, and the two answers diverge whenever an intermediate router's decision RULE or INPUT SET differs from the source's: an ABR that examines only backbone summary-LSAs (RFC 2328 §16 step (3), RFC 3509 §2.2), an intra-area path that outranks a cheaper summary (§16.2 (6)), a longest-prefix difference, or a differing AD mix. The chain is a reconstruction from recorded routing evidence, not a packet trace or confirmation of forwarding-plane state.
Consequences for consumers, all additive:
path_model says which engine answered; terminated says how the walk ended; traversed_cost reports the links actually crossed next to total_cost, which is now the source's own installed metric — literally what show ip route prints there, including the destination loopback's stub cost. The destination loopback's stub cost can make this differ from the SPF cost to the destination device.
- Per hop,
route_metric / route_type / ecmp_branches / proven explain the step.
- The source-rooted corridor moves to
source_view / source_view_reverse, unchanged.
- Where the chain is not computable the response is byte-for-byte today's corridor with
path_model: "spf" and a non-empty model_note naming the reason; the notice (projection warning + reason) renders as its own explanation step, and the caveat banner carries nothing for it — never a blank panel and never a silent reinstatement of the old picture. That applies to: OSPFv3 (loopbacks are /128s Osprey records as management addresses, not stub networks, so nothing anchors the destination's host route), IS-IS (zero Type-3 summary rows, so L1↔L2 leaking cannot be reproduced), EIGRP (no link-state database — it keeps its own observed chain), a scope holding only part of a protocol instance's areas, an area carrying a virtual link (RFC 2328 §16.3 transit paths unmodelled), and a router in two protocol instances that can both carry the address family.
- Stitched multi-domain paths get the same treatment per IGP segment; the stitched
caveat names the AS of every segment that fell back, and path_model stays empty when segments used different engines.
- Simulation is deliberately NOT migrated: a post-mutation chain is not computable (the Type-3 summaries surviving ABRs would re-originate cannot be synthesised), so both simulation sides stay on the SPF projection and say so in
warning / notices.
Failed path diagnostics: When no path is found (total_cost: -1), the response automatically includes an explanation: RouteStep[] array with a step-by-step diagnosis of why the path failed. This covers: shared area analysis, backbone reachability, entry/exit ABR analysis for inter-area paths, and area membership mismatches. No ?explain=true parameter is needed — failed path diagnostics are always included. In multi-instance scopes, backbones are counted per protocol instance (backbone 0.0.0.0 (AS 65100 'DC', ospfv2/1): 4 devices, 3 links; …) and ABR lists carry an instance suffix (abr-x [ospfv2/1 @ AS 65100]) — one summed count would misread as a single broken backbone.
Cross-domain verdict (device mode): When source and destination share no protocol instance and no routing domain (different AS, tenant network, or VRF), the handler skips SPF entirely and returns a verdict: both directions carry total_cost: -1 with an explanation stating the endpoints' domains, and the top-level domain_info object identifies both endpoints (asn, as_name, network_name, instances) plus an optional bgp_chain_hint (e.g. "65100 → 65000 → 65200") when a BMP exporter in either AS has an AS_PATH linking them. Endpoints that share a routing domain but no protocol instance (e.g. RFC 5838 OSPFv2+OSPFv3 side by side) are NOT a verdict: computation proceeds, and the only permitted join is a device present in both instances — such a path carries a caveat ("…possible redistribution, not verified in the LSDBs."). ABRs are never paired across protocol instances. Prefix mode never returns the verdict — it resolves cross-domain destinations through the BGP longest-prefix race.
BGP-stitched multi-domain path (device mode): On a cross-domain verdict, the handler additionally tries to stitch the dataplane path from BMP evidence: RIB longest-match for the destination router-id selects the border in the source domain (IGP-cost race between candidate exporters), the RIB row's resolved next-hop is the next domain's entry, and each subsequent domain exits via the evidence ladder — (a) a RIB row at a border in that domain (resolved), (a2) a route server in an intermediate AS that peers with both domains and whose own BMP RIB covers the destination via the chain's next AS (inferred, evidence bmp-rib-rs — a transparent RS never appears in the AS_PATH, so this rung is the only way to see an IX detour), (b) an established eBGP session toward the next AS on the chain (inferred; from BMP Peer Up/Down or an SNMP BGP4-MIB walk — see bgp_peer.source), (c) nothing → the rest of the chain renders as opaque segments with a note, never guessed hops. When an AS on the chain has no areas in the selected scope at all, the note says exactly that ("AS 65000 has no areas in the selected scope — include its areas to trace through this domain") — the actionable cause, not a generic "entry unknown". A stitched result sets top-level path_kind: "multi-domain" (regular device-mode results carry path_kind: "igp" — consumers must test path_kind, not total_cost, because a stitched path deliberately keeps total_cost: -1: costs from different domains never sum). The stitched PathInfo carries domain_segments[] (asn, as_name, network_name, instance, start_hop/end_hop — -1 for hopless opaque segments — segment_cost, confidence, note) and each domain-entry hop carries ebgp_hop (local_as, peer_as, evidence: "bmp-rib"|"bmp-peer"|"snmp-peer" — with an +l2 suffix when the far side was resolved through the borders' LLDP/CDP adjacency instead of an address match). Segments whose hops include an operationally-up TE tunnel head-end are capped at inferred with a "TE steering may differ" note. Time-travel (at=) requests are stitched — historical answers read the BGP history tables, so evidence classes that are not historized are disclosed as explanation steps. The domain_info verdict object stays on stitched results. L2 detail on transitions: each eBGP transition step and the entry hop's ebgp_hop are enriched with physical L2 detail from the borders' LLDP/CDP adjacency — but only what is provable: exactly one link pair and no fabric alternative → ports as fact ("pe1 Et1/0 → pe2 Et1/0"); parallel links → an aggregate ("3 parallel L2 links: Gi2↔Et1/0, … (session link undetermined)"); both borders on a shared bridge-capable device and no direct link → fabric presence with attachment ports ("shared L2 fabric: ix-switch (… ↔ …)"); direct link AND shared fabric → explicit ambiguity naming both, choosing neither. Fabric statements are presence, never a session-path claim. A shared bridge matched only on sysname (or seen only in stale rows) still vetoes port claims but is never displayed as fact. Supporting rows must be fresher than 13h (age disclosed above 6h), endpoints concrete, rows matched undirected; one L2 query per unique border device per stitching pass. Session-IP binding: when both session addresses resolve through the devices' IP-MIB bindings (device_ip, walked on the discovery pass), the ports become proven facts that override the ambiguity gates — "edge1 Gi3 → edge2 Et1/1 (bmp-rib; ip-bound)" — and a bound port matching a fabric attachment upgrades fabric presence to a per-session claim ("ip-bound, transits ix-switch"). Canvas: eBGP transitions draw as path-styled overlay edges between the borders; a proven fabric transit routes the line over the bridge node, and merely-possible fabric devices get a dashed 'possible' marker, never path styling.
SR-MPLS label stack enrichment: In path-to-prefix mode with af=ipv4 or af=ipv6, the response is enriched with SR-MPLS labels when SR data is available. Each hop in the path gets a label field with the computed MPLS label (per RFC 8660: SRGB base + Prefix SID index for intermediate hops, Implicit NULL label 3 for PHP at the penultimate hop). The top-level sr_enabled flag indicates whether SR data was found. Label enrichment is additive — if no SR data exists, the path response is unchanged. CLNS address family (af=clns) does not support SR label enrichment. ECMP alternate paths are also enriched when present.
Response (device-to-device): 200 OK
{
"forward_path": {
"hops": [
{
"device_id": "uuid-source",
"router_id": "10.0.0.1",
"hostname": "core-01",
"link_id": "",
"cost": 0,
"area_id": "uuid-area0",
"area_label": "0.0.0.0",
"in_interface": null,
"out_interface": { "name": "Gi0/0", "ip": "10.0.0.1", "speed": 1000, "if_index": 1 }
},
{
"device_id": "uuid-abr",
"router_id": "10.0.0.2",
"hostname": "abr-01",
"link_id": "uuid-link-1",
"cost": 10,
"area_id": "uuid-area0",
"area_label": "0.0.0.0",
"is_abr": true,
"in_interface": { "name": "Gi0/1", "ip": "10.0.0.2", "speed": 1000, "if_index": 2 },
"out_interface": { "name": "Gi0/2", "ip": "10.1.0.1", "speed": 10000, "if_index": 3 }
},
{
"device_id": "uuid-dest",
"router_id": "10.0.0.3",
"hostname": "edge-05",
"link_id": "uuid-link-2",
"cost": 10,
"area_id": "uuid-area1",
"area_label": "0.0.0.1",
"in_interface": { "name": "Gi0/0", "ip": "10.1.0.2", "speed": 10000, "if_index": 1 },
"out_interface": null
}
],
"total_cost": 20,
"route_type": "O IA",
"area_segments": [
{ "area_id": "uuid-area0", "area_label": "0.0.0.0", "start_hop": 0, "end_hop": 1, "cost": 10 },
{ "area_id": "uuid-area1", "area_label": "0.0.0.1", "start_hop": 2, "end_hop": 2, "cost": 10 }
],
"ecmp_paths": [
[
{ "device_id": "uuid-source", "router_id": "10.0.0.1", "hostname": "core-01", "cost": 0, "area_id": "uuid-area0", "area_label": "0.0.0.0" },
{ "device_id": "uuid-alt-hop", "router_id": "10.0.0.4", "hostname": "core-02", "link_id": "uuid-link-3", "cost": 10, "area_id": "uuid-area0", "area_label": "0.0.0.0" },
{ "device_id": "uuid-dest", "router_id": "10.0.0.3", "hostname": "edge-05", "link_id": "uuid-link-4", "cost": 10, "area_id": "uuid-area1", "area_label": "0.0.0.1" }
]
],
"ecmp_count": 2
},
"reverse_path": {
"hops": [ "..." ],
"total_cost": 30,
"route_type": "O IA",
"area_segments": [ "..." ]
}
}
Response (path-to-prefix): 200 OK
{
"forward_path": {
"hops": [ "..." ],
"total_cost": 1,
"route_type": "E2",
"area_segments": [ "..." ],
"external_route": {
"prefix": "0.0.0.0/0",
"metric_type": 2,
"metric": 1,
"adv_router": "10.0.0.5",
"fwd_addr": "10.0.0.6",
"tag": 0,
"is_nssa": false
}
},
"reverse_path": null
}
PathResult fields:
| Field |
Type |
Description |
forward_path |
PathInfo |
Forward path from source to destination |
reverse_path |
PathInfo |
Reverse path from destination to source (device-to-device mode only, null in path-to-prefix) |
source_addr |
string |
Source address (omitempty, echo of query parameter) |
dest_addr |
string |
Destination address (omitempty, echo of query parameter) |
protocol |
string |
Detected protocol: ospfv2, ospfv3, or isis (omitempty) |
address_family |
string |
Address family used: ipv4, ipv6, or clns (omitempty) |
sr_enabled |
bool |
True if SR-MPLS data was available and labels were attached to path hops. Only set in path-to-prefix mode with af=ipv4 or af=ipv6. |
domain_info |
PathDomainInfo |
Cross-domain verdict (device mode only): endpoints live in different routing domains, no IGP path can exist. Contains source/destination endpoints (device_id, label, asn, as_name, network_id, network_name, instances[]) and optional bgp_chain_hint (omitempty) |
path_kind |
string |
Device-mode discriminator: igp (single-domain SPF) or multi-domain (BGP-stitched segments, total_cost stays -1). Consumers must test this, not cost (omitempty; absent in prefix mode) |
source_view / source_view_reverse |
PathInfo |
The SOURCE-ROOTED least-cost corridor for each direction, present only when forward_path/reverse_path carry a hop-by-hop forwarding chain instead (path_model: "chain") and explain=true (audit A10: the corridor is always computed internally, but this second answer is serialized only for diagnosing callers). Two fields because the corridor is computed independently per direction and real forwarding is asymmetric (omitempty) |
resolved_dest_addr / resolved_dest_kind |
string |
The address the path was actually computed toward, and what it is (router_id_loopback — the chain runs in device mode only; prefix mode keeps the source-rooted model and reports no chain fields). Device mode is genuinely ambiguous between a router's loopback and any of its interfaces — the choice can change both the metric and the hops — so the answer declares which question it answered. Distinct from dest_addr, which stays the echoed request parameter (omitempty) |
PathInfo fields:
| Field |
Type |
Description |
hops |
PathHop[] |
Ordered list of hops from source to destination |
total_cost |
int |
Total path cost (-1 if unreachable) |
route_type |
string |
Route type in the protocol's own vocabulary — OSPF O/O IA/E1/E2/N1/N2, IS-IS I L1/I L2/I IA, EIGRP D/D EX, BGP B/Bi, plus C (see table below) |
area_segments |
AreaSegment[] |
Consecutive hops grouped by area |
ecmp_paths |
PathHop[][] |
Alternative equal-cost paths (omitted when no ECMP). Each entry is a full hop sequence with the same schema as hops |
ecmp_count |
int |
Total number of equal-cost paths including the primary (omitted when no ECMP) |
explanation |
RouteStep[] |
(When ?explain=true) Ordered steps describing route computation reasoning (omitted by default) |
external_route |
ExternalRouteInfo |
External route metadata (present only in path-to-prefix mode) |
caveat |
string |
Advisory on an unverified assumption, e.g. a path crossing protocol instances via a shared device — possible redistribution. Prefix mode also sets it when the only candidate left in scope is a prefix the source advertises itself but is not attached to (an IS-IS L1L2 router re-advertises its level-1 area in its level-2 LSP): the answer is the advertisement, and the real next hop lives in an area outside the requested scope (omitempty) |
domain_segments |
DomainSegment[] |
Per-domain segments of a stitched multi-domain path: asn, as_name, network_name, instance, start_hop/end_hop (-1 = hopless opaque), segment_cost (-1 = unknown), segment_fd/segment_cd (observed-EIGRP segments only: the entry device's DUAL feasibility threshold resp. CURRENT computed distance toward the destination — prefer segment_cd, §62), confidence (resolved/inferred/opaque), note (omitempty) |
path_model |
string |
Which engine produced the hops: chain (hop-by-hop — every router's own routing table) or spf (the source-rooted least-cost corridor, which intermediate routers may legitimately disagree with). Empty on prefix mode, on the RIB/simulation consumers, and on a stitched path whose segments used different engines — the per-AS caveat then says what happened where (omitempty) |
model_note |
string |
Why the hop-by-hop chain could not be produced, when path_model fell back to spf — the machine-readable fallback signal (with path_model). The explanation renders one "Hop-by-hop forwarding chain unavailable" step carrying the projection warning plus this rationale; the caveat banner carries nothing for it. On stitched multi-domain paths the notes are AS-prefixed and joined with ; , one explanation step per fallback segment (omitempty) |
terminated |
string |
End state of a forwarding chain: delivered, delivered_elsewhere, no_route, next_hop_unknown, loop, max_hops, area_out_of_scope, unmodelled. Anything but delivered also carries a non-empty caveat (omitempty) |
traversed_cost |
int |
Sum of the link costs the packet actually crosses plus the destination's own stub cost. A different true statement from total_cost, which is the SOURCE's routing-table metric: the two diverge whenever an inter-area route was priced against an ABR the packet never reaches (RFC 2328 §16.2), and the caveat then names both numbers. -1 when a hop cost is unknown or the chain never delivered; absent on SPF paths (omitempty) |
PathHop fields:
| Field |
Type |
Description |
device_id |
string |
UUID of the device at this hop |
router_id |
string |
OSPF router ID |
hostname |
string |
Device hostname (omitempty) |
system_id |
string |
IS-IS system ID, e.g. 0100.0000.0001 (omitempty, populated when af=clns) |
nets |
string[] |
Full NET addresses, e.g. ["49.0001.0100.0000.0001.00"] (omitempty, populated when af=clns) |
link_id |
string |
UUID of the link traversed to reach this hop (empty for source) |
cost |
int |
Incremental cost of this hop (0 for source) |
area_id |
string |
UUID of the area at this hop |
area_label |
string |
Dotted-quad area label (e.g. "0.0.0.0") or IS-IS level label |
is_abr |
bool |
True if this device is an ABR (omitempty) |
in_interface |
HopInterface |
Ingress interface at this hop (omitempty). When af=clns, ip is omitted. |
out_interface |
HopInterface |
Egress interface at this hop (omitempty). When af=clns, ip is omitted. |
label |
int |
SR-MPLS label for this hop (omitempty). Present only in path-to-prefix mode when SR data is available. Value 3 = Implicit NULL (PHP). Value -1 = SRGB data unavailable for this hop. |
route_metric |
int |
THIS router's own installed metric toward the path's destination address — the number show ip route prints here. Forwarding chains only. Not monotonically decreasing: a step UP is the signature of an inter-area inconsistency (the previous router priced its route against a cheap summary from an ABR the packet never reaches). Distinct from fd (DUAL's feasibility threshold — a historical minimum, RFC 7868 §3.4), cd (the router's CURRENT computed distance — prefer this as the hop metric) and rd (successor-reported distance), all omitempty (§62) |
route_type |
string |
This router's own path type for that route (C, O, O IA, E1, I L1, I L2, …) — the per-hop counterpart of PathInfo.route_type (omitempty) |
ecmp_branches |
int |
Size of this router's installed next-hop set (1 = no branch). Without it a single drawn chain would hide a real load-sharing split (omitempty) |
proven |
bool |
This hop cannot be refused by any router: the destination is anchored in exactly one area, both ends of the hop are members of it, and the winning route is that area's intra-area route — which RFC 2328 §16.2 (6) makes unbeatable by any summary regardless of metric. Everything else (an ABR crossing the §16 step (3) gate, an external, a differing AD mix, a longest-prefix difference) is modelled, not proven. The guarantee is set MEMBERSHIP, not uniqueness (omitempty) |
ebgp_hop |
EBGPHopInfo |
Set when this hop was reached over an eBGP session (stitched multi-domain path): local_as, peer_as, evidence (bmp-rib, bmp-peer, or snmp-peer; +l2 suffix = far side resolved via the borders' LLDP/CDP adjacency). When the borders' LLDP/CDP adjacency identifies exactly one link pair, also local_port, peer_port and l2_age_secs (age of the freshest supporting row, seconds); parallel links render as an aggregate in the explanation step instead of naming one arbitrary pair. l2_fabric[] lists bridge-capable devices adjacent to both borders (device_id, name, from_local_port/from_fabric_port, to_local_port/to_fabric_port, age_secs) — fabric PRESENCE, never a session-path claim. l2_bound: true marks ports proven by the devices' own IP-MIB bindings (device_ip) for the session addresses — facts even with parallel links or a shared fabric; transit_fabric names the bridge the bound session provably transits. rs_name/rs_device_id name the route server whose BMP RIB selected the transition (evidence bmp-rib-rs; forwarding role hedged — a transparent RS may or may not be a dataplane hop) (omitempty) |
HopInterface fields:
| Field |
Type |
Description |
name |
string |
Interface name (e.g. "Gi0/0", "eth0") |
ip |
string |
IP address on the interface (omitempty) |
speed |
int |
Interface speed in Mbps (omitempty) |
if_index |
int |
SNMP ifIndex (omitempty, null when not discovered) |
AreaSegment fields:
| Field |
Type |
Description |
area_id |
string |
UUID of the OSPF area |
area_label |
string |
Dotted-quad area label |
start_hop |
int |
Index of the first hop in this segment (0-based) |
end_hop |
int |
Index of the last hop in this segment (0-based, inclusive) |
cost |
int |
Sum of incremental costs across the segment |
RouteStep fields (explanation array):
| Field |
Type |
Description |
step |
int |
Step number (1-indexed for readability in logs/UI) |
description |
string |
Human-readable explanation of this step (e.g., "Selected intra-area route over inter-area", "Found ASBR via Type 4 LSA", "E1 metric (10 + 5) = 15 preferred over E2") |
detail |
string |
(Optional) Additional technical context (e.g., route cost, competing alternative metrics, metric type comparison) |
ExternalRouteInfo fields:
| Field |
Type |
Description |
prefix |
string |
CIDR prefix (e.g. "0.0.0.0/0") |
metric_type |
int |
1 (E1/N1) or 2 (E2/N2) |
metric |
int |
External metric value |
adv_router |
string |
Router ID of the advertising ASBR |
fwd_addr |
string |
Forwarding address (omitempty, null when 0.0.0.0) |
tag |
int |
External route tag (omitempty) |
is_nssa |
bool |
True if this is an NSSA Type-7 route (omitempty) |
Route types: C (connected), O (intra-area), O IA (inter-area), E1 (external type 1), E2 (external type 2), N1 (NSSA type 1), N2 (NSSA type 2), B/Bi (eBGP/iBGP), D/D EX (EIGRP internal/external).
IS-IS route types: I L1 (a route learned in the router's own level-1 area) and I L2 (a route computed by SPF inside the level-2 backbone). The level comes from the area's area_type, not from OSPF's intra/inter axis: a path that never leaves the level-2 backbone is I L2, and one that crosses from a level-1 area into the backbone is I L2 as well. I IA marks a prefix leaked into a level-1 area with the up/down bit set (Cisco prints i ia). There is no i E1/i E2 on IOS — a redistributed prefix appears as plain i L2.
Algorithm: Area-aware Dijkstra per RFC 2328 Section 16. Intra-area paths (O) are always preferred over inter-area (O IA) regardless of metric. Inter-area paths transit the backbone (area 0.0.0.0) through ABRs as a 3-segment path: source area to entry ABR, backbone traversal, exit ABR to destination area. For path-to-prefix, external route selection follows RFC 2328 Section 16.4: E1 routes are always preferred over E2; E1 sorts by total cost (path + external metric); E2 sorts by external metric with path cost as tiebreaker. ECMP alternatives are enumerated up to 8 paths via multi-predecessor Dijkstra.
If no path exists, total_cost is -1 and hops is an empty array.
Error responses:
400 Bad Request — Missing parameters, source == destination, both destination and prefix specified, invalid af value, af=clns combined with prefix, or no areas in scope
404 Not Found — Source or destination device not found in topology
IS-IS Address Families
GET /api/v1/topology/isis-address-families?pi_id={piID}
Returns the address family visibility and enabled state for an IS-IS protocol instance. Used by the Route Path AF selector to determine which AF buttons to show.
Auth: all authenticated roles
Query parameters:
pi_id (required) — UUID of the IS-IS protocol instance
Response: 200 OK
{
"address_families": [
{ "name": "clns", "visible": true, "enabled": true },
{ "name": "ipv4", "visible": true, "enabled": true },
{ "name": "ipv6", "visible": true, "enabled": false }
]
}
Address family fields:
| Field |
Type |
Description |
name |
string |
Address family name: clns, ipv4, or ipv6 |
visible |
bool |
Whether the AF button should be shown (based on TLV 129 NLPID presence or data-presence fallback for SNMP-only) |
enabled |
bool |
Whether actual data exists for this AF in the database |
Visibility logic:
- IPv4: visible if any device declares NLPID 0xCC (204) in
protocols_supported (TLV 129). Enabled if stub_network rows with family(network)=4 exist.
- IPv6: visible if any device declares NLPID 0x8E (142) in
protocols_supported (TLV 129). Enabled if stub_network rows with family(network)=6 exist.
- CLNS: always visible for IS-IS. Enabled if
isis_area_address rows exist for this protocol instance.
- Fallback: if no TLV 129 data exists (SNMP-only collectors),
visible = enabled (data presence determines both).
Error responses:
400 Bad Request — Missing pi_id parameter
Compare Topology (Diff)
GET /api/v1/topology/diff?area_id={areaID}&from={RFC3339}&to={RFC3339}
Compares topology at two points in time, returning devices, links, and stub networks that were added, removed, or changed. Pure computation on existing snapshot data — no new database tables required.
Query parameters (scope — mutually exclusive, one required):
area_id — UUID of the area
pi_id — UUID of the protocol instance (multi-area diff)
rd_id — UUID of the routing domain (multi-area diff)
network_id — UUID of the network (multi-area diff)
Required:
from — RFC 3339 timestamp for the baseline snapshot
Optional:
to — RFC 3339 timestamp for the comparison snapshot. If omitted, live topology is used.
Response: 200 OK
{
"from_time": "2026-02-22T10:00:00Z",
"to_time": "2026-02-23T10:00:00Z",
"summary": {
"devices_added": 1,
"devices_removed": 0,
"devices_changed": 2,
"links_added": 1,
"links_removed": 1,
"links_changed": 0,
"stubs_added": 3,
"stubs_removed": 0
},
"devices": {
"added": [{ "router_id": "10.0.0.5", "hostname": "rtr-05" }],
"removed": [],
"changed": [{ "router_id": "10.0.0.1", "hostname": "rtr-01", "detail": { "old_abr": false, "new_abr": true } }]
},
"links": {
"added": [{ "source_router_id": "10.0.0.1", "target_router_id": "10.0.0.5" }],
"removed": [{ "source_router_id": "10.0.0.2", "target_router_id": "10.0.0.3" }],
"changed": []
},
"stubs": {
"added": [{ "network": "192.168.1.0/24", "device_router_id": "10.0.0.5" }],
"removed": []
}
}
Diff algorithm:
- Devices: Indexed by
router_id (stable canonical identifier). Set difference for added/removed. For common devices, compares IsABR, IsASBR, and Hostname flags.
- Links: Normalized to
(min(srcRouterID, dstRouterID), max(...)) key. Set difference for added/removed. For common links, compares CostForward, CostReverse, and State.
- Stubs: Keyed by
(network, deviceRouterID). Set difference for added/removed.
Error responses:
400 Bad Request — Missing from parameter or invalid timestamp format
500 Internal Server Error — Database error
Per-Router RIB
GET /api/v1/topology/rib?device_id={deviceID}&area_id={areaID}
GET /api/v1/topology/rib?device_id={deviceID}&pi_id={piID}
GET /api/v1/topology/rib?device_id={deviceID}&rd_id={rdID}
GET /api/v1/topology/rib?device_id={deviceID}&network_id={networkID}
GET /api/v1/topology/rib?device_id={deviceID}&area_id={areaID}&at={RFC3339}
Computes the full RIB (Routing Information Base) for a specific router from LSDB data, in the vocabulary of whatever protocol each area speaks (OSPF, IS-IS or EIGRP). Runs SPF from the target router's perspective, then assembles intra-area, inter-area, and external routes with next-hop resolution.
Query parameters:
Required:
device_id — UUID of the router to compute the RIB for
Scope (mutually exclusive, one required):
area_id — UUID of the area
pi_id — UUID of the protocol instance (multi-area RIB)
rd_id — UUID of the routing domain (multi-area RIB)
network_id — UUID of the network (multi-area RIB)
Optional:
at — RFC 3339 timestamp for historical mode. Runs SPF against historical topology and route validity at T. EIGRP D/D EX candidates come only from complete per-device snapshots covering T; there is no live fallback. A future timestamp is rejected.
explain — Boolean query parameter. When true, each route includes an explanation: RouteStep[] array with route computation reasoning (e.g., why intra-area was preferred over inter-area, external metric type selection).
Response: 200 OK
{
"device_id": "uuid",
"router_id": "10.0.0.1",
"hostname": "rtr-core-01",
"route_count": 42,
"routes": [
{
"prefix": "192.168.1.0/24",
"type": "O",
"metric": 20,
"next_hops": ["10.0.0.2"],
"via_area": "0.0.0.0",
"adv_router": "10.0.0.3",
"tag": 0,
"explanation": [
{
"step": 1,
"description": "Intra-area route computed via SPF",
"detail": "Device reachable via direct link cost 20"
}
]
}
]
}
Historical responses can additionally contain history_scope and
eigrp_history. history_scope.coverage=available means only that historical
membership/topology scope was found; it is deliberately not a freshness claim
for OSPF/IS-IS. history_scope.coverage=unavailable with reason
device_scope_unavailable means the device had no historical area membership;
it is not an empty RIB. eigrp_history is omitted when no EIGRP evidence was
consulted—an OSPF/IS-IS-only historical RIB therefore never claims sampled
across zero devices. When present, it reports coverage, the weakest primary
temporal_evidence, RIB/ECMP evidence quality, device count, and conservative
observation/confirmation/lease bounds. Missing EIGRP coverage omits unprovable
D/D EX rows and is shown as partial/unavailable, while a covered snapshot
whose route list is empty is a genuine empty observation. Store/decode/hash
errors return 500 rather than masquerading as missing history. rib_ecmp is
computed only from D/D EX routes that contribute to the returned RIB. An
optional alternate-tail gap preserves primary coverage and
temporal_evidence, sets rib_ecmp=partial, and identifies the gap through
rib_unavailable_device_id plus rib_reason=ecmp_history_unavailable.
Route fields:
| Field |
Type |
Description |
prefix |
string |
CIDR prefix (e.g., "192.168.1.0/24") |
type |
string |
Route type (C, O, O IA, E1, E2, N1, N2) |
metric |
int |
OSPF metric/cost |
next_hops |
string[] |
List of next-hop router IDs |
via_area |
string |
Dotted-quad area label (0.0.0.0 for intra-area, backbone or destination area for inter-area) |
adv_router |
string |
Advertising router ID (for external routes, the ASBR) |
tag |
int |
OSPF route tag (0 for internal routes) |
explanation |
RouteStep[] |
(When ?explain=true) Detailed route computation reasoning |
Route types:
C — Connected (directly attached network). In OSPF "advertised by this router" and "connected" are the same thing; in IS-IS they are not, because an L1L2 router re-advertises its whole level-1 area in its level-2 LSP. For an IS-IS area the row is only typed C when the advertised cost is 0 (the router's own loopback/passive interface), or one of the device's own addresses in the prefix's own address family falls inside it. Addresses come from the device's interface rows (IPv4 ip_address and IPv6 ipv6_address) plus, on live requests only, its device_ip rows (device_ip is IPv4-only — the SNMP walk behind it reads ipAdEntIfIndex, and it has no as-of form, so a time-travel request reads the interface rows as of at and skips it). With no address known in that family the level rule decides: a level-1 self-advertisement is trusted (a level-1 LSP carries only attached prefixes), a level-2 one only for a router that is in no level-1 area in scope
O — Intra-area (SPF-computed within a single area)
O IA — Inter-area (learned via Type 3 Summary-LSA from ABR)
E1 — External type 1 (metric = internal cost + external metric)
E2 — External type 2 (metric = external metric only, default)
N1 — NSSA type 1 (Type 7 LSA, metric includes internal cost)
N2 — NSSA type 2 (Type 7 LSA, external metric only)
I L1 — IS-IS level-1 (learned in the router's own level-1 area)
I L2 — IS-IS level-2 (SPF inside the level-2 backbone — an ordinary route there, not a summary)
I IA — IS-IS leaked prefix (up/down bit set; Cisco prints i ia)
D / D EX — EIGRP internal / redistributed (observed from the device's own topology table)
Route codes for a routing domain always follow that domain's protocol: a device in an IS-IS area never returns OSPF codes, and administrative distance follows the code (routing.ad.isis, default 115, for the I * family). Within one AD the route type ranks before the metric — intra-area before inter-area before external (RFC 2328 §11/§16), level-1 before level-2 (RFC 1195 §3.10.1: "IS-IS will always make use of a path within the area (via level 1 routing), regardless of whether an alternate path exists outside of the area").
A prefix an IS-IS router advertises itself but is not attached to (its level-2 re-advertisement of a level-1 prefix) has no next hop in the answer — the hop it really forwards on lives in the level-1 area. Such a row is held back and returned only when nothing else in scope covers the prefix, and explain=true then adds a "No next hop in scope" step saying the row is the advertisement, not an installed forwarding entry.
Auth: Required (all roles).
Error responses:
400 Bad Request — Missing device_id or scope parameter
404 Not Found — Device not found in the specified scope
500 Internal Server Error — Database error
SPF Tree Visualization
GET /api/v1/topology/spf-tree?root={deviceID}&area_id={areaID}
GET /api/v1/topology/spf-tree?root={deviceID}&area_id={areaID}&at={RFC3339}
Computes the full SPF shortest-path tree rooted at a given device within a single OSPF area. The result is a recursive tree structure suitable for hierarchical visualization (e.g., radial tree layout, indented tree, or collapsible tree diagram).
The algorithm runs Dijkstra from the root device, inverts the predecessor map to build a parent-to-children mapping, then performs a DFS to construct the tree. Children at each level are sorted by cumulative cost (ascending), with device ID as tiebreaker.
Query parameters:
Required:
root -- UUID of the device to use as the SPF tree root
area_id -- UUID of the area to compute the tree within
Optional:
at -- RFC 3339 timestamp for historical mode (uses nearest topology snapshot)
Response: 200 OK
{
"root": {
"device_id": "uuid",
"router_id": "10.0.0.1",
"hostname": "rtr-core-01",
"cost": 0,
"link_cost": 0,
"is_abr": true,
"children": [
{
"device_id": "uuid",
"router_id": "10.0.0.2",
"hostname": "rtr-dist-01",
"cost": 10,
"link_cost": 10,
"link_id": "uuid",
"children": [
{
"device_id": "uuid",
"router_id": "10.0.0.3",
"hostname": "rtr-access-01",
"cost": 15,
"link_cost": 5,
"link_id": "uuid",
"is_asbr": true
}
]
}
]
},
"total_nodes": 3,
"area_id": "uuid",
"area_label": "0.0.0.0"
}
Tree node fields:
device_id -- UUID of the device
router_id -- OSPF router ID (dotted-quad)
hostname -- Device hostname (omitted if empty)
cost -- Cumulative SPF cost from root to this node
link_cost -- Incremental cost of the edge from parent to this node (0 for root)
link_id -- UUID of the link connecting this node to its parent (empty for root)
is_abr -- True if the device is an ABR (omitted if false)
is_asbr -- True if the device is an ASBR (omitted if false)
children -- Array of child nodes (omitted if empty/leaf)
Auth: Required (all roles).
Error responses:
400 Bad Request -- Missing root or area_id parameter, or invalid at timestamp
404 Not Found -- Root device not found in the specified area
500 Internal Server Error -- Database error