Topology (Read)
List Devices
GET /api/v1/devices?area_id={areaID}&limit=100&offset=0
List devices in a selected scope. Supports pagination.
Supply at least one scope. Precedence is area_ids, area_id, pi_id,
rd_id, then network_id; lower-priority selectors are ignored.
Devices belonging to several selected areas appear once, by device UUID. Pagination and total apply to the complete selected union.
An empty scope returns data: [] and total: 0. These are live lists,
not a consistent snapshot across successive requests.
The additional scope selectors are available from 1.4.4;
release 1.4.3 requires area_id. Existing single-area requests keep their behavior.
Conditional requests (from 1.4.4): the response includes an
ETag. Save it with the response for this exact list URL, then send it in
If-None-Match on a later GET. An unchanged page returns 304 Not Modified
with no body; reuse the cached page. Without that header, the ordinary 200
JSON response remains unchanged. Authentication and validation run every time.
Validators include scope, pagination, authenticated identity and all returned
fields, including timestamps and total. Recorder refreshes that change
last_seen therefore change the validator. Responses use Cache-Control: private, no-cache and vary on authentication headers. Weak validators work
across gzip and uncompressed responses. Matching uses standard If-None-Match
semantics, including weak/strong tag comparison, tag lists and * for an
existing successful representation.
This saves transfer bytes only: the server still reads and encodes the current
page. No database cache or snapshot guarantee is introduced. Clients using
older installations should accept a normal 200 without an ETag.
Query parameters:
area_ids (optional) — Comma-separated area UUIDs; blank or invalid entries return 400
area_id (optional when another scope is supplied) — UUID of the area
pi_id (optional) — Protocol instance UUID
rd_id (optional) — Routing domain UUID
network_id (optional) — Network UUID
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": "Cisco",
"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": "Cisco",
"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 links in a selected scope. Supports pagination.
Supply at least one scope. Precedence is area_ids, area_id, pi_id,
rd_id, then network_id; lower-priority selectors are ignored.
Links retain their observation UUIDs; parallel circuits and separate protocol observations are not merged. Pagination and total apply to the complete selected union.
An empty scope returns data: [] and total: 0. These are live lists,
not a consistent snapshot across successive requests.
The additional scope selectors are available from 1.4.4;
release 1.4.3 requires area_id. Existing single-area requests keep their behavior.
Conditional requests (from 1.4.4): the response includes an
ETag. Save it with the response for this exact list URL, then send it in
If-None-Match on a later GET. An unchanged page returns 304 Not Modified
with no body; reuse the cached page. Without that header, the ordinary 200
JSON response remains unchanged. Authentication and validation run every time.
Validators include scope, pagination, authenticated identity and all returned
fields, including timestamps and total. Recorder refreshes that change
last_seen therefore change the validator. Responses use Cache-Control: private, no-cache and vary on authentication headers. Weak validators work
across gzip and uncompressed responses. Matching uses standard If-None-Match
semantics, including weak/strong tag comparison, tag lists and * for an
existing successful representation.
This saves transfer bytes only: the server still reads and encodes the current
page. No database cache or snapshot guarantee is introduced. Clients using
older installations should accept a normal 200 without an ETag.
Query parameters:
area_ids (optional) — Comma-separated area UUIDs; blank or invalid entries return 400
area_id (optional when another scope is supplied) — UUID of the area
pi_id (optional) — Protocol instance UUID
rd_id (optional) — Routing domain UUID
network_id (optional) — Network UUID
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
Recorder-adjacent edges preserve the stored status: they are not forced Up.
Duplicate observations of the same recorder/peer pair prefer an observed Up
edge; if all are Down, the retained edge is Down. Equal-status ties use the
lowest edge ID. This applies to live and historical rendering. Recorder nodes
remain monitoring artifacts (not isolated production routers); retaining a
withdrawn node/link follows the normal retention policy, not continued liveness.
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": "Cisco",
"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: Models the source's candidate selection (external forwarding-address limits below): 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), external E1/E2 ranking per §16.4 (positive live OSPFv2/IPv4 FA matches use covering-network costs from retained input; missing coverage keeps the warned ASBR assumption, and historical lookup is unchanged), 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: Intra-area, inter-area, external, bounded live EIGRP and BGP collect candidates. The winner is selected by lowest administrative distance (configurable via
routing.ad.* system settings). Default: eBGP=20, internal EIGRP=90, OSPF=110, IS-IS=115, external EIGRP=170, iBGP=200. Connected routes (AD 0) always win.
- Response includes
source ("ospf", "ospfv3", "isis", "eigrp", "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) a parsed destination prefix supplies its IPv4/IPv6 family, including IS-IS prefix requests; (3) clns for IS-IS device requests; (4) 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.
- IPv6 area selection: for a destination belonging exclusively to one known OSPFv3/IPv6 instance, a single uniquely owned IPv6 /128 in the loaded topology can anchor its area corridor. This avoids an unanchored non-backbone shortcut. Explicit prefix anchors and existing router-ID anchors retain priority. Multiple host addresses, shared ownership or missing scope retain the previous fallback. This does not change the chain's destination address or certify every hop;
path_model and model_note still describe the returned model.
- 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 when the chain's selected router-ID address has no matching host route (stored IPv6 /128s can anchor the area corridor without enabling this per-hop model), 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.
Live EIGRP prefix paths: a source in one identified EIGRP protocol instance
can use its observed chain for an exact IPv4 or IPv6 prefix in the default VRF.
Every retained hop must select that same prefix, family, instance and EIGRP AS;
a complete chain ends at a connected network. source: "eigrp", D/D EX,
configured administrative distance and terminated: "delivered_prefix" identify
this result. model_note explicitly withholds address/device-delivery proof.
total_cost remains -1: EIGRP per-router distances are not additive link costs.
Installed alternatives use ecmp_paths without claiming equal costs or traffic
shares. Existing installation caveats remain visible. A covering aggregate,
more-specific hop, loop, missing row, read failure or scope mismatch supplies no
EIGRP candidate; missing data never proves router absence. Missing/covering-route
notes appear only when no path is returned. A failed reconstruction warns on
another protocol's winner only when an exact, non-connected source row in the
same instance/family/default VRF could beat or tie that winner's configured
administrative distance. Exact rows are checked independently of the lookup
address: a more-specific hit does not erase an exact row's uncertainty. A failed
source read leaves both internal and external EIGRP routes possible, so the
lower of their configured distances bounds the warning. An equal-preference
tie also warns. A winner with strictly better preference receives no EIGRP
reconstruction warning. An already
connected source receives a local-network explanation when this chain view
cannot render a path. Without dest_addr, the network address is only a lookup key.
This exact candidate competes with same-prefix BGP/IGP candidates by configured
preference; a shorter BGP prefix cannot displace it. Equal cross-protocol
preference keeps selection among the remaining candidates with a warning rather
than pricing unknown EIGRP link costs. The shorter BGP candidate has already
been excluded, including when EIGRP subsequently declines a tie with another IGP. This gate changes only the new EIGRP candidate's race;
it is not a general rewrite of prefix-length selection. Historical prefix
requests, device paths and cross-domain stitching retain their existing logic.
In prefix mode, a parsed prefix supplies the address family for mixed area_ids
and the response label, even without endpoint addresses or af. An explicit
IPv4/IPv6 family or endpoint address contradicting the prefix returns HTTP 400.
For mixed-family scopes, a source outside the selected areas returns HTTP 400
with "source is not in the selected scope". A source present only in the other
family returns HTTP 400 with "source has no IPv4/IPv6 area in the selected scope";
these are scope errors, not a claim that the device is missing. A failed
membership read returns HTTP 500. Historical prefix questions use the source's
membership from snapshots at the requested at time, not current membership;
the live path continues to read current membership.
Equivalent IPv6 spellings are canonicalized before exact-prefix selection.
These family/text rules also apply to OSPF and other prefix queries; they do not
activate historical EIGRP chains or change device-mode family selection.
Failed path diagnostics: When no path is found (no forward hops; total_cost: -1 alone also occurs on valid observed EIGRP chains), 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.
Prefix without an exact route and longest match: the IGP steps of a prefix question match the requested prefix exactly. A question that names an address — dest_addr inside the requested prefix, or a host prefix (/32, /128) — follows router longest match (RFC 1812 §5.2.4.3) when no in-scope IGP row carries the requested prefix: the most specific in-scope IGP route containing the address (stub network, interface subnet, inter-area summary or external route, including a default route) is answered instead, unless a BGP route at least as specific already won. The response then carries matched_prefix, the forward path equals the answer for that prefix, and the explanation (explain=true) starts with Longest match. For such address questions an exact IGP candidate also outranks a shorter covering BGP prefix regardless of administrative distance; questions without an address keep the existing preference order. A router holding a more specific route that Osprey does not observe would forward differently. Sources in EIGRP scope keep exact-prefix answers, because retained EIGRP input can lack host routes. Without an address, or when the longest-match route cannot be reached, a no-path answer names the most specific covering route as No exact prefix route instead of No route exists; that route is not traced. Coverage uses only rows already loaded for the request; a longest-match answer runs the prefix race once more for the matched prefix. An exact row without a path, an EIGRP refusal (which stays the first step) and device-mode diagnostics keep their existing explanation.
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 explicit or prefix-derived ipv4/ipv6 (including IS-IS without af), 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) |
matched_prefix |
string |
Prefix mode only: the most specific known IGP route containing the address of a question that names one (dest_addr or host prefix) when no route carries the requested prefix exactly. The forward path is the answer for this prefix (longest match, RFC 1812 §5.2.4.3), not proof of delivery to the address (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. For an IS-IS prefix answered inside the destination's level-1 area, an alternative may end at a different router — several routers in one level-1 area can advertise the same prefix at the same cost, and the advertising L1L2 router load-shares over them |
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). Eligible live OSPF inter-area prefix requests with an explicit dest_addr can use chain, ending at the connected network (delivered_prefix), with unchanged source metric/type/preference. This requires a complete single-instance/family scope, successful input reads, exact target-network ownership and agreement with the original source decision. Otherwise summary-prefix answers retain spf and the advertising-router projection note. Other prefix answers may omit it. Empty 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. For delivered_prefix, the note instead explains successful network termination and its limits, without a fallback warning. On stitched multi-domain paths the notes are AS-prefixed and joined with ; , one explanation step per fallback segment (omitempty). Ordinary live device/prefix paths also append TE steering limitations (known operational head-end on primary/ECMP hops, or failed inventory lookup), with a separate explanation step; this does not change path_model or costs. Historical requests do not use current tunnel inventory. |
terminated |
string |
End state of a forwarding chain: delivered, delivered_prefix (connected network reached in the retained model; address/device delivery unverified), delivered_elsewhere, no_route, next_hop_unknown, loop, max_hops, area_out_of_scope, unmodelled. Incomplete chains carry a caveat; successful delivered_prefix instead explains the network/address boundary in model_note (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. For delivered_prefix, this is the reconstructed link sum plus the terminating interface advertisement cost; the connected hop's route_metric is zero. Equal source-metric variants can have different traversed costs. -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 route metric toward the path's destination address. Prefix chains reconstruct it from retained observations; they do not query or certify the installed routing table. The terminating connected-network hop has metric zero and does not distinguish local receive from neighbor delivery. 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 §§2.2/3.3), 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 |
Recorded forwarding address, when available |
fwd_addr_status |
string |
Optional lookup classification: zero (no nonzero FA in an observation that supplies FA information), interface_match (exact IP match in interfaces embedded in the loaded topology), unresolved (no such match). An interface match does not prove an intra/inter-area route to the FA. Absent means unavailable/unannotated, never zero; rows without an FA in BGP-LS-marked areas omit it because the projection does not decode FA information. A row with an explicit FA retains its classification even in a mixed area. |
ecmp_fwd_addr_statuses |
string[] |
Same values, aligned with PathInfo.ecmp_paths (alternatives only). The whole array is omitted when any alternative lacks FA annotation, or when there are no alternatives; omission is not an all-clear. |
tag |
int |
External route tag (omitempty) |
is_nssa |
bool |
True if this is an NSSA Type-7 route (omitempty) |
Live OSPFv2/IPv4 prefix answers with a recorded nonzero forwarding address can
price external candidates through a covering internal OSPF network in the same
protocol instance. The internal cost includes the cost into that network: E1/N1
adds the external metric, while E2/N2 uses the internal cost to break equal
external metrics. The lookup uses the longest internal prefix, then applies
Type-5/Type-7 area eligibility. It is not a lookup across the installed routing
tables of other protocols. OSPF compatibility preferences and Type-4 ASBR
reachability are not fully modelled.
This is a retained-model cost correction, not a freshness guarantee.
Interfaces, stubs and summaries may outlive their withdrawal. Every evaluated
answer carries a caveat about that limit and selection_assumed: true.
Missing, unreadable, unsuitable or unrenderable coverage keeps the existing
forwarding assumption; this correction does not exclude advertisements or
claim network unreachability. Ordinary zero/absent-FA answers are unchanged
unless another nonzero-FA candidate participates in the calculation.
| Additional field |
Type |
Meaning |
ExternalRouteInfo.fwd_addr_resolution |
object |
Primary candidate evidence, when its external candidate group contains a corrected route. Supersedes fwd_addr_status for that answer. |
ExternalRouteInfo.ecmp_fwd_addr_resolutions |
object[] |
Evidence aligned with displayed alternative paths only; an unevaluated alternative is explicitly unknown. Supersedes the legacy alternative-status array. |
PathResult.fwd_addr_evaluation |
object |
Counts considered nonzero-FA candidates: evaluated_candidates, unknown_candidates, excluded_candidates (always zero for this correction), plus selection_assumed, optional reasons and limitations. Counts are not a measure of influence on the winner. |
Resolution objects have status (route_match, zero, unknown; the contract
also reserves no_route_in_model), optional fwd_addr, reason, lookup_basis
(ospf_internal), matched_prefix, route_type (intra_area or inter_area),
area_id, protocol_instance_id, igp_cost, network_cost and limitations.
network_cost is included in igp_cost, never added twice. An absent cost is
unknown, not zero. route_match means a route in the retained model; the drawn
hops lead to its attachment router and need not sum to the network cost.
Limitations are input_lifecycle_unverified, rfc1583_preference_unmodelled,
asbr_type4_unmodelled, operator_declared and retained_rows_not_versioned.
Reasons are scope_unavailable, fa_not_observed, invalid_forwarding_address,
index_unavailable, no_internal_cover, area_type_unknown and
area_policy_rejected. A reason for retaining an assumption does not establish
that the network has no route.
Historical answers keep the existing exact-interface lookup in stored graphs;
they are not switched to the live covering-network calculation. Other protocol
families and missing scope metadata likewise keep their existing calculation.
The legacy statuses above remain valid for those answers and live fallbacks.
An unresolved displayed candidate or potentially decisive unresolved rival
retains its ASBR-assumption warning. A relevant nil-FA advertisement in a
BGP-LS-marked area still warns about assuming zero without evidence; an explicit
FA on another row in that area is not erased by the area marker.
The explanation uses the supplied network cost for a corrected candidate and
states the retention limit. Uncorrected candidates retain their old explanation.
No additional query, polling or refresh is introduced. Hop-chain and RIB
calculations are not switched by this prefix correction; absent annotations
there do not prove resolution or installed forwarding.
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