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:

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:

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:

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 /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:

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):

Optional:

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:

Edge dual-stack merging:

Per-protocol metadata (multi-protocol merged edges):

Edge state aggregation (merged edges):

Edge cost display logic:

Node data fields:

Node classes:

Edge classes:

Error responses:

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:

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):

Optional:

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:

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:

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):

Optional:

Response: 200 OK

{
  "timestamps": [
    "2026-02-22T10:15:00Z",
    "2026-02-22T10:10:00Z",
    "2026-02-22T10:05:00Z"
  ]
}

Error responses:

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):

Required:

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:

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:

The algorithm respects routing rules:

Query parameters (scope — mutually exclusive, one required):

Required:

Destination (one required, mutually exclusive):

Optional:

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:

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:

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:

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:

Error responses:

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):

Required:

Optional:

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:

Error responses:

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:

Scope (mutually exclusive, one required):

Optional:

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:

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:

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:

Optional:

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:

Auth: Required (all roles).

Error responses: