Compare commits

..

No commits in common. "31286d5d3e983585204dfe0250bd490dbf3024fd" and "c28c9b2527abdcba294b868b1a77bc91a6b5bf0b" have entirely different histories.

41 changed files with 393 additions and 3903 deletions

View File

@ -1,53 +0,0 @@
#!/bin/bash
# Build the ExaBGP Docker image and export it for CML 2.9 import.
#
# Usage:
# ./cml/build-cml-image.sh
#
# Output:
# /tmp/obmp-exabgp.tar — upload this to CML via:
# Tools > Node and Image Definitions > Image Definitions > Manage Image Uploads
#
# After upload, also import the node + image definition YAMLs:
# Tools > Node and Image Definitions > Import > cml/exabgp-node-definition.yaml
# Tools > Node and Image Definitions > Import > cml/exabgp-image-definition.yaml
set -e
cd "$(dirname "$0")/.."
echo "=== Building ExaBGP Docker image ==="
docker build -t obmp-exabgp:latest ./exabgp/
echo ""
echo "=== Exporting image to /tmp/obmp-exabgp.tar ==="
docker save -o /tmp/obmp-exabgp.tar obmp-exabgp:latest
echo ""
echo "=== Image details ==="
SIZE=$(du -h /tmp/obmp-exabgp.tar | cut -f1)
echo " File: /tmp/obmp-exabgp.tar ($SIZE)"
SHA=$(sha256sum /tmp/obmp-exabgp.tar | awk '{print $1}')
echo " SHA256: $SHA"
IMAGE_ID=$(docker image inspect obmp-exabgp:latest --format='{{.Id}}')
echo " Image ID: $IMAGE_ID"
echo ""
echo "=== Next steps ==="
echo "1. Update cml/exabgp-image-definition.yaml with:"
echo " sha256: $SHA"
echo ""
echo "2. Upload to CML:"
echo " a. Tools > Node and Image Definitions > Import"
echo " Upload: cml/exabgp-node-definition.yaml"
echo " b. Tools > Node and Image Definitions > Import"
echo " Upload: cml/exabgp-image-definition.yaml"
echo " c. Tools > Node and Image Definitions > Image Definitions > Manage Image Uploads"
echo " Upload: /tmp/obmp-exabgp.tar"
echo ""
echo "3. In your CML lab topology:"
echo " a. Drag 'ExaBGP Route Injector' from the node palette"
echo " b. Draw links to CORE-01 and CORE-02"
echo " c. Edit the boot.sh in the node config to set correct IPs"
echo " d. Start the node"

View File

@ -1,62 +0,0 @@
#!/bin/bash
# Export the XRd control-plane Docker image for CML 2.9 import.
#
# Usage:
# ./cml/build-xrd-image.sh
#
# The XRd image already exists locally (ios-xr/xrd-control-plane:25.1.1).
# This script just exports it to a .tar file for CML upload.
set -e
IMAGE="ios-xr/xrd-control-plane:25.1.1"
OUTPUT="/tmp/xrd-control-plane.tar"
echo "=== Verifying XRd image exists ==="
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
echo "ERROR: Image $IMAGE not found locally."
echo "Check with: docker images | grep xrd"
exit 1
fi
echo " Image: $IMAGE"
SIZE=$(docker image inspect "$IMAGE" --format='{{.Size}}' | numfmt --to=iec 2>/dev/null || echo "unknown")
echo " Size: $SIZE"
echo ""
echo "=== Exporting image to $OUTPUT ==="
echo " (This may take a minute for ~1.3GB image...)"
docker save -o "$OUTPUT" "$IMAGE"
echo ""
echo "=== Export complete ==="
TAR_SIZE=$(du -h "$OUTPUT" | cut -f1)
echo " File: $OUTPUT ($TAR_SIZE)"
SHA=$(sha256sum "$OUTPUT" | awk '{print $1}')
echo " SHA256: $SHA"
echo ""
echo "=== Next steps ==="
echo "1. Update cml/xrd-image-definition.yaml with:"
echo " sha256: $SHA"
echo ""
echo "2. Upload to CML:"
echo " a. Tools > Node and Image Definitions > Import"
echo " Upload: cml/xrd-node-definition.yaml"
echo " b. Tools > Node and Image Definitions > Import"
echo " Upload: cml/xrd-image-definition.yaml"
echo " c. Tools > Node and Image Definitions > Image Definitions > Manage Image Uploads"
echo " Upload: $OUTPUT"
echo " (For large files, consider SCP to CML server instead)"
echo ""
echo "3. In your CML lab topology:"
echo " a. Drag 'XRd Control-Plane (IOS-XR)' from the node palette"
echo " b. Draw links to CORE-01 (→Gi0/0/0/0) and CORE-02 (→Gi0/0/0/1)"
echo " c. Edit xrd-startup.cfg if needed (IPs, BMP target, etc.)"
echo " d. Start the node (allow ~3-5 min for XRd boot)"
echo ""
echo "4. After boot, verify via XRd console:"
echo " show isis adjacency"
echo " show bgp summary"
echo " show bmp server 1"

View File

@ -1,10 +0,0 @@
id: obmp-exabgp.latest
node_definition_id: obmp-exabgp
description: |-
OpenBMP ExaBGP Route Injector
Python 3.11 + ExaBGP + Flask API for BGP route injection testing.
label: ExaBGP Route Injector
disk_image: obmp-exabgp.tar
read_only: false
schema_version: 0.0.1
# sha256: <UPDATE after running: sha256sum /tmp/obmp-exabgp.tar>

View File

@ -1,112 +0,0 @@
id: obmp-exabgp
boot:
timeout: 60
completed:
- "ExaBGP Route Injector"
uses_regex: false
sim:
linux_native:
libvirt_domain_driver: docker
driver: ubuntu
ram: 512
cpus: 1
cpu_limit: 100
video:
memory: 1
general:
nature: server
description: OpenBMP ExaBGP Route Injector (Docker container)
read_only: false
configuration:
generator:
driver: null
provisioning:
files:
- editable: false
name: config.json
content: |-
{
"docker": {
"image": "obmp-exabgp:latest",
"mounts": [
"type=bind,source=cfg/boot.sh,target=/cml-boot.sh"
],
"misc_args": [],
"env": [
"EXABGP_LOCAL_AS=65100",
"EXABGP_PEER_AS=65020",
"EXABGP_API_PORT=5050"
]
},
"shell": "/bin/bash",
"day0cmd": [ "/bin/bash", "/cml-boot.sh" ],
"busybox": false
}
- editable: true
name: boot.sh
content: |-
#!/bin/bash
# CML boot script for ExaBGP container
# Configures data-plane interfaces before starting ExaBGP
#
# Interface mapping (assigned by CML topology links):
# eth0 = first connected interface (data-plane link 1)
# eth1 = second connected interface (data-plane link 2)
# ...additional interfaces as connected in topology
#
# Edit the IPs below to match your topology addressing.
# These are examples using 10.120.x.x/30 point-to-point links.
# --- Data-plane interface configuration ---
# Link to CORE-01: ExaBGP=10.120.1.2/30, CORE-01=10.120.1.1/30
ip address add 10.120.1.2/30 dev eth0
ip link set dev eth0 up
# Link to CORE-02: ExaBGP=10.120.2.2/30, CORE-02=10.120.2.1/30
ip address add 10.120.2.2/30 dev eth1
ip link set dev eth1 up
# --- Set environment for ExaBGP peering ---
export EXABGP_LOCAL_IP=10.120.1.2
export EXABGP_PEER_1=10.120.1.1
export EXABGP_PEER_2=10.120.2.1
# --- Start ExaBGP ---
exec /bin/bash /exabgp/startup.sh
media_type: raw
volume_name: cfg
device:
interfaces:
serial_ports: 1
physical:
- eth0
- eth1
- eth2
- eth3
has_loopback_zero: false
default_count: 2
ui:
label_prefix: exabgp-
icon: server
label: ExaBGP Route Injector
visible: true
group: Others
description: |-
OpenBMP ExaBGP Route Injector
BGP route injection for OpenBMP testing.
AS 65100 (eBGP) peering with IOS-XR routers (AS 65020).
Flask API on port 5050 for route management.
inherited:
image:
ram: true
cpus: false
data_volume: false
boot_disk_size: false
cpu_limit: false
node:
ram: true
cpus: false
data_volume: false
boot_disk_size: false
cpu_limit: false
schema_version: 0.0.1

View File

@ -1,10 +0,0 @@
id: xrd-control-plane.25.1.1
node_definition_id: xrd-control-plane-rr
description: |-
Cisco XRd Control-Plane 25.1.1
IOS-XR containerized routing daemon for BGP/IS-IS/BMP workloads.
label: XRd Control-Plane 25.1.1
disk_image: xrd-control-plane.tar
read_only: false
schema_version: 0.0.1
# sha256: <UPDATE after running: sha256sum /tmp/xrd-control-plane.tar>

View File

@ -1,179 +0,0 @@
id: xrd-control-plane-rr
boot:
timeout: 300
completed:
- "IOS XR RUN"
uses_regex: false
sim:
linux_native:
libvirt_domain_driver: docker
driver: ubuntu
ram: 2048
cpus: 2
cpu_limit: 100
video:
memory: 1
general:
nature: router
description: Cisco XRd Control-Plane - IOS-XR containerized routing daemon
read_only: false
configuration:
generator:
driver: null
provisioning:
files:
- editable: false
name: config.json
content: |-
{
"docker": {
"image": "ios-xr/xrd-control-plane:25.1.1",
"mounts": [
"type=bind,source=cfg/boot.sh,target=/cml-boot.sh",
"type=bind,source=cfg/xrd-startup.cfg,target=/etc/xrd/startup.cfg"
],
"misc_args": [
"--privileged"
],
"env": [
"XR_STARTUP_CFG=/etc/xrd/startup.cfg",
"XR_MGMT_INTERFACES=linux:eth0,chksum",
"XR_INTERFACES=linux:eth1,xr_name=Gi0/0/0/0;linux:eth2,xr_name=Gi0/0/0/1;linux:eth3,xr_name=Gi0/0/0/2;linux:eth4,xr_name=Gi0/0/0/3"
]
},
"shell": "/bin/bash",
"day0cmd": [ "/bin/bash", "/cml-boot.sh" ],
"busybox": false
}
- editable: true
name: boot.sh
content: |-
#!/bin/bash
# CML boot wrapper for XRd control-plane.
# XRd handles its own init — this script configures
# data-plane interfaces before XRd starts.
#
# Interface mapping (set via XR_INTERFACES env var):
# eth0 = MgmtEth0/RP0/CPU0/0 (CML mgmt)
# eth1 = Gi0/0/0/0 (data-plane link 1, e.g. to CORE-01)
# eth2 = Gi0/0/0/1 (data-plane link 2, e.g. to CORE-02)
# eth3+ = Gi0/0/0/2+ (additional links)
#
# Linux-level IP config is handled by XRd via startup.cfg.
# Just ensure interfaces are up.
for iface in eth0 eth1 eth2 eth3 eth4; do
[ -d /sys/class/net/$iface ] && ip link set dev $iface up
done
# XRd entrypoint
exec /usr/sbin/xrd
- editable: true
name: xrd-startup.cfg
content: |-
!! XRd Control-Plane - Third Route Reflector (RR3)
!! Peers with CORE-01 and CORE-02 as RR mesh (non-client iBGP)
!! Sends BMP to OpenBMP collector at 10.40.40.202:5000
!!
hostname XRd-RR3
!
interface Loopback0
ipv4 address 10.10.255.30 255.255.255.255
!
interface Gi0/0/0/0
description to-CORE-01
ipv4 address 10.120.3.2 255.255.255.252
no shutdown
!
interface Gi0/0/0/1
description to-CORE-02
ipv4 address 10.120.4.2 255.255.255.252
no shutdown
!
router isis 1
is-type level-2-only
net 49.0001.0100.1000.0030.00
address-family ipv4 unicast
metric-style wide
!
interface Loopback0
passive
address-family ipv4 unicast
!
!
interface Gi0/0/0/0
point-to-point
address-family ipv4 unicast
!
!
interface Gi0/0/0/1
point-to-point
address-family ipv4 unicast
!
!
!
router bgp 65020
bgp router-id 10.10.255.30
address-family ipv4 unicast
!
neighbor 10.10.255.0
remote-as 65020
update-source Loopback0
address-family ipv4 unicast
!
!
neighbor 10.10.255.20
remote-as 65020
update-source Loopback0
address-family ipv4 unicast
!
!
!
bmp server 1
host 10.40.40.202 port 5000
description OpenBMP
update-source Gi0/0/0/0
flapping-delay 60
initial-delay 5
stats-reporting-period 300
initial-refresh delay 30 spread 2
!
ssh server v2
end
media_type: raw
volume_name: cfg
device:
interfaces:
serial_ports: 1
physical:
- eth0
- eth1
- eth2
- eth3
- eth4
has_loopback_zero: false
default_count: 3
ui:
label_prefix: xrd-
icon: router
label: XRd Control-Plane (IOS-XR)
visible: true
group: Cisco
description: |-
Cisco XRd Control-Plane (IOS-XR 25.1.1)
Containerized IOS-XR routing daemon for control-plane workloads.
Full BGP, IS-IS, BMP, NETCONF support.
Configured as third Route Reflector (RR3) with BMP to OpenBMP.
inherited:
image:
ram: true
cpus: true
data_volume: false
boot_disk_size: false
cpu_limit: false
node:
ram: true
cpus: true
data_volume: false
boot_disk_size: false
cpu_limit: false
schema_version: 0.0.1

View File

@ -92,13 +92,7 @@ services:
- ${OBMP_DATA_ROOT}/grafana/provisioning:/etc/grafana/provisioning/ - ${OBMP_DATA_ROOT}/grafana/provisioning:/etc/grafana/provisioning/
environment: environment:
- GF_SECURITY_ADMIN_PASSWORD=openbmp - GF_SECURITY_ADMIN_PASSWORD=openbmp
- GF_AUTH_ANONYMOUS_ENABLED=false - GF_AUTH_ANONYMOUS_ENABLED=true
- GF_SERVER_ROOT_URL=https://bmp.apodacalab.com/grafana/
- GF_SERVER_SERVE_FROM_SUB_PATH=true
- GF_AUTH_PROXY_ENABLED=true
- GF_AUTH_PROXY_HEADER_NAME=Remote-User
- GF_AUTH_PROXY_HEADER_PROPERTY=username
- GF_AUTH_PROXY_AUTO_SIGN_UP=true
- GF_USERS_HOME_PAGE=d/obmp-home/obmp-home - GF_USERS_HOME_PAGE=d/obmp-home/obmp-home
- GF_INSTALL_PLUGINS=agenty-flowcharting-panel,grafana-piechart-panel,grafana-worldmap-panel,grafana-simple-json-datasource,vonage-status-panel - GF_INSTALL_PLUGINS=agenty-flowcharting-panel,grafana-piechart-panel,grafana-worldmap-panel,grafana-simple-json-datasource,vonage-status-panel
@ -281,9 +275,8 @@ services:
- NET_RAW - NET_RAW
- NET_ADMIN - NET_ADMIN
environment: environment:
- TRAFFIC_GEN_PORT=5051 - TRAFFIC_GEN_API_PORT=5051
- TRAFFIC_GEN_MODE=sender - TRAFFIC_GEN_MODE=sender
- RESPONDER_URL=http://172.30.0.10:5053
traffic-gen-ui: traffic-gen-ui:
restart: unless-stopped restart: unless-stopped
@ -294,26 +287,6 @@ services:
network_mode: host network_mode: host
# Serves on port 5002 (host network, defined in nginx.conf) # Serves on port 5002 (host network, defined in nginx.conf)
traffic-gen-responder:
restart: unless-stopped
container_name: obmp-traffic-gen-responder
build:
context: ./traffic-gen
dockerfile: Dockerfile
cap_add:
- NET_RAW
- NET_ADMIN
environment:
- TRAFFIC_GEN_PORT=5053
- TRAFFIC_GEN_MODE=responder
- TRAFFIC_GEN_RESPONDER_MODE=echo
- TRAFFIC_GEN_INTERFACE=eth0
networks:
traffic-test-net:
ipv4_address: 172.30.0.10
ports:
- "5053:5053"
whois: whois:
restart: unless-stopped restart: unless-stopped
container_name: obmp-whois container_name: obmp-whois
@ -332,30 +305,3 @@ services:
- POSTGRES_DB=openbmp - POSTGRES_DB=openbmp
- POSTGRES_HOST=obmp-psql - POSTGRES_HOST=obmp-psql
- POSTGRES_PORT=5432 - POSTGRES_PORT=5432
authelia:
restart: unless-stopped
container_name: obmp-authelia
image: authelia/authelia:4.38
ports:
- "9091:9091"
volumes:
- ${OBMP_DATA_ROOT}/authelia:/config
environment:
- TZ=UTC
portal:
restart: unless-stopped
container_name: obmp-portal
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./portal:/usr/share/nginx/html:ro
networks:
traffic-test-net:
driver: bridge
ipam:
config:
- subnet: 172.30.0.0/24

View File

@ -1,269 +0,0 @@
# OpenBMP Platform Roadmap
## Context
This BMP monitoring platform is being developed against CML virtual labs (IOS-XR) and will be deployed into an ISP production network running IOS-XR and Juniper routers/route reflectors. The two tracks share a common foundation: configuration must be environment-agnostic so the same stack runs identically against virtual or production routers.
Currently, router IPs, AS numbers, and credentials are hardcoded across 8+ files, tightly coupling the stack to a single CML lab. This roadmap addresses both the multi-lab development workflow and production deployment.
---
## Track A: Configuration Centralization (Foundation for Both Tracks)
### A1. Create `inventory.yaml` — unified topology inventory
**File**: `inventory.yaml` (new)
Single source of truth for all environments. Structure:
```yaml
platform:
host_ip: 10.40.40.202
bmp_port: 5000
exabgp_port: 5050
environments:
cml-lab1:
type: cml # cml | production
description: "CML RR cluster - 9 IOS-XR virtual routers"
cml_server: "https://10.40.40.174"
cml_user: webui
bgp_as: 65020
netconf: { user: webui, password: cisco, port: 830 }
exabgp:
local_as: 65100
peers:
- { ip: 10.100.0.100, name: CORE-01, peer_as: 65020 }
- { ip: 10.100.0.200, name: CORE-02, peer_as: 65020 }
routers:
CORE-01: { mgmt: 10.100.0.100, loopback: 10.10.255.0, role: rr, vendor: iosxr, gnmi: true }
CORE-02: { mgmt: 10.100.0.200, loopback: 10.10.255.20, role: rr, vendor: iosxr, gnmi: true }
R9K-01: { mgmt: 10.100.0.1, loopback: 10.10.255.1, role: client, vendor: iosxr }
# ...
cml-lab2:
type: cml
description: "Second CML Lab (TBD topology)"
cml_server: "https://<lab2-ip>"
routers: {}
production:
type: production
description: "ISP production network"
bgp_as: <prod-as>
netconf: { user: <prod-user>, port: 830 }
routers:
# IOS-XR and Juniper RRs + routers
PROD-RR1: { mgmt: x.x.x.x, role: rr, vendor: iosxr, gnmi: true }
PROD-RR2: { mgmt: x.x.x.x, role: rr, vendor: junos }
# ...
```
Key design decisions:
- `vendor: iosxr | junos` — drives NETCONF dialect, gNMI paths, and config templates
- `type: cml | production` — CML environments have `cml_server` for API automation; production does not
- Credentials in `inventory.yaml` (gitignored) or pulled from env vars
### A2. Create `config_loader.py` — Python inventory helper
**File**: `config_loader.py` (new)
Functions: `get_env(name)`, `get_all_routers()`, `get_routers_by_vendor(vendor)`, `get_exabgp_peers()`, `get_gnmi_targets()`, `get_routers_for_env(env_name)`
### A3. Refactor hardcoded Python scripts
Replace `ROUTERS` dicts/lists with `config_loader` calls:
- `exabgp/route_diversity_config.py` (line 47)
- `exabgp/bgpls_config.py` (line 35)
- `gnmi/gnmi_grpc_config.py` (line 25)
### A4. Expand `.env` and parameterize `docker-compose.yml`
Add to `.env`:
```env
OBMP_DATA_ROOT=/var/openbmp
DOCKER_HOST_IP=10.40.40.202
EXABGP_LOCAL_IP=10.40.40.202
EXABGP_LOCAL_AS=65100
EXABGP_PEER_AS=65020
EXABGP_PEER_1=10.100.0.100
EXABGP_PEER_2=10.100.0.200
```
Replace hardcoded IPs in `docker-compose.yml` (Kafka listener, ExaBGP env vars).
### A5. Telegraf config parameterization
Replace hardcoded gNMI addresses in `telegraf/telegraf.conf` with env var substitution. Pass `GNMI_TARGETS` from docker-compose.yml.
### A6. Fix InfluxDB datasource URL
`obmp-grafana/provisioning/datasources/influxdb-ds.yml`: replace `http://10.40.40.202:8086` with `http://obmp-influxdb:8086`.
---
## Track B: Multi-Lab CML Development
### B1. Dynamic ExaBGP multi-peer support
**File**: `exabgp/startup.sh`
Accept `EXABGP_PEERS` env var (comma-separated `ip:as:description`), generate N neighbor blocks. Keep `PEER_1`/`PEER_2` fallback.
### B2. CML API client module
**File**: `cml/cml_client.py` (new)
Python module using `virl2_client` SDK:
- Connect to CML server (creds from `inventory.yaml`)
- Upload node/image definitions
- Import/export topology YAML
- Start/stop/destroy labs
- Get node status
### B3. Topology template system
**File**: `cml/templates/xrd_rr.j2` (new)
Jinja2 templates for XRd startup config. Parameterize: hostname, loopback, link IPs, IS-IS NET, BGP AS, neighbor IPs, BMP target.
### B4. CLI deployment tool
**File**: `cml/deploy.py` (new)
```bash
python3 cml/deploy.py --env cml-lab1 status
python3 cml/deploy.py --env cml-lab1 upload-images
python3 cml/deploy.py --env cml-lab2 create
python3 cml/deploy.py --env cml-lab2 start
python3 cml/deploy.py --env cml-lab2 destroy
```
### B5. Update build scripts with API push
`cml/build-cml-image.sh` and `cml/build-xrd-image.sh` get `--push <env-name>` flag.
---
## Track C: Production ISP Deployment
### C1. Multi-vendor NETCONF support
Current scripts assume IOS-XR NETCONF only. For Juniper RRs:
- `config_loader.py` provides `vendor` field per router
- NETCONF scripts branch on vendor for dialect differences (`device_params='iosxr'` vs `device_params='junos'`)
- Route diversity, BGP-LS config scripts get Junos templates alongside IOS-XR
### C2. Multi-vendor gNMI paths
Telegraf gNMI subscriptions currently use OpenConfig paths which work for both IOS-XR and Junos, but:
- Verify Juniper gNMI support on target hardware
- Add vendor-specific path overrides in `inventory.yaml` if needed
- Telegraf can subscribe to multiple targets with different configs via `[[inputs.gnmi]]` blocks
### C3. BMP considerations for production
- BMP collector (port 5000) accepts connections from any router — no changes needed
- Production routers need BMP config pushed (manual or via NETCONF automation)
- Consider: separate BMP server IDs per environment for dashboard filtering
- Juniper BMP config differs from IOS-XR — add Junos BMP config templates
### C4. Dashboard multi-environment awareness
- Add a Grafana template variable for environment filtering (by router name prefix or a tag)
- Consider a "Network Overview" dashboard that shows all environments side-by-side
- Existing dashboards work as-is — router dropdowns will show all BMP-reporting routers
### C5. Security hardening for production
- Move credentials out of `inventory.yaml` into environment variables or a secrets manager
- Authelia config: stronger passwords, TOTP enforcement, session timeouts
- PostgreSQL: restrict access, enable SSL
- Kafka: consider authentication if exposed beyond localhost
- BMP port: firewall to only accept connections from known router management IPs
### C6. Scalability considerations
- Monitor PostgreSQL disk usage and query performance with production-scale RIBs
- TimescaleDB compression policies for historical data (ip_rib_log, ls_*_log)
- Kafka topic partitioning if message throughput is high
- Consider read replicas or materialized views for heavy Grafana queries
---
## Track D: Packaging & Distribution
### D1. Configuration templates
- `inventory.yaml.example` — documented example with placeholder values
- `.env.example` — all environment variables with descriptions
### D2. Bootstrap script
`setup.sh` that:
- Creates required directories (`$OBMP_DATA_ROOT/authelia`, etc.)
- Copies example configs if originals don't exist
- Validates inventory.yaml syntax
- Generates Telegraf config from inventory
### D3. Published Docker images
Push custom images to a registry (Docker Hub or GHCR):
- `obmp-exabgp`
- `obmp-exabgp-ui`
- `obmp-traffic-gen`
- `obmp-traffic-gen-ui`
- `obmp-portal`
Replace `build:` with `image:` in docker-compose.yml (keep build as override).
### D4. Documentation
- `docs/quickstart.md` — 5-minute setup guide
- `docs/adding-a-lab.md` — how to add a CML lab environment
- `docs/production-deployment.md` — production hardening checklist
- `docs/architecture.md` — system diagram, data flow, port map
---
## Implementation Order
| Priority | Step | Track | Description |
|----------|------|-------|-------------|
| 1 | A1 | Foundation | Create `inventory.yaml` |
| 2 | A2 | Foundation | Create `config_loader.py` |
| 3 | A3 | Foundation | Refactor hardcoded Python scripts |
| 4 | A4 | Foundation | Parameterize `.env` + docker-compose |
| 5 | A5-A6 | Foundation | Telegraf + InfluxDB datasource fixes |
| 6 | B1 | CML Dev | Dynamic ExaBGP multi-peer |
| 7 | B2-B4 | CML Dev | CML API client + deploy CLI |
| 8 | C1 | Production | Multi-vendor NETCONF (Junos support) |
| 9 | C3 | Production | Junos BMP config templates |
| 10 | C5 | Production | Security hardening |
| 11 | D1-D2 | Packaging | Config templates + bootstrap script |
| 12 | D3 | Packaging | Publish Docker images to registry |
| 13 | D4 | Packaging | Documentation |
Steps 1-5 (Track A) unblock everything else. Steps 6-7 and 8-10 can proceed in parallel once the foundation is in place.
---
## Verification
1. **Config centralization**: Change a router IP in `inventory.yaml`, verify all scripts pick it up
2. **ExaBGP multi-peer**: Set 3+ peers, restart, verify BGP sessions establish
3. **CML API**: `deploy.py --env cml-lab1 status` connects and lists nodes
4. **BMP multi-source**: Router from lab 2 sends BMP, appears in `SELECT * FROM routers` and Grafana
5. **Junos support**: NETCONF script connects to a Juniper router, pushes config
6. **Production dry-run**: Point a test router from the ISP network at the collector, verify end-to-end
7. **Clean deploy**: Clone repo on a fresh host, run `setup.sh`, `docker compose up`, confirm stack starts
---
## Risks
- **Router name collisions**: Enforce unique hostnames across all environments
- **Address space overlap**: Each environment needs distinct management subnets
- **Juniper BMP differences**: Junos BMP implementation may differ in supported tables/TLVs — test early
- **Production scale**: 500K-route labs are slow; production full tables will stress PostgreSQL more
- **Credentials in inventory**: Must be gitignored; consider env var fallback for CI/CD

View File

@ -41,7 +41,6 @@
<AnnounceForm v-else-if="activeTab === 'inject'" @routes-changed="fetchRoutes" /> <AnnounceForm v-else-if="activeTab === 'inject'" @routes-changed="fetchRoutes" />
<PeerStatus v-else-if="activeTab === 'peers'" :peers="peers" /> <PeerStatus v-else-if="activeTab === 'peers'" :peers="peers" />
<ChurnControl v-else-if="activeTab === 'churn'" /> <ChurnControl v-else-if="activeTab === 'churn'" />
<FullTable v-else-if="activeTab === 'full-table'" @routes-changed="fetchRoutes" />
</div> </div>
</main> </main>
</div> </div>
@ -64,7 +63,6 @@ import RouteTable from './components/RouteTable.vue'
import AnnounceForm from './components/AnnounceForm.vue' import AnnounceForm from './components/AnnounceForm.vue'
import PeerStatus from './components/PeerStatus.vue' import PeerStatus from './components/PeerStatus.vue'
import ChurnControl from './components/ChurnControl.vue' import ChurnControl from './components/ChurnControl.vue'
import FullTable from './components/FullTable.vue'
const health = ref(null) const health = ref(null)
const routes = ref([]) const routes = ref([])
@ -77,7 +75,6 @@ const tabs = [
{ id: 'inject', label: 'Inject' }, { id: 'inject', label: 'Inject' },
{ id: 'peers', label: 'Peers' }, { id: 'peers', label: 'Peers' },
{ id: 'churn', label: 'Churn' }, { id: 'churn', label: 'Churn' },
{ id: 'full-table', label: 'Full Table' },
] ]
async function fetchHealth() { async function fetchHealth() {

View File

@ -1,4 +1,4 @@
const BASE = '/exabgp/api' const BASE = '/api'
async function req(method, path, body) { async function req(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } } const opts = { method, headers: { 'Content-Type': 'application/json' } }
@ -18,7 +18,4 @@ export const api = {
announce: payload => req('POST', '/announce', payload), announce: payload => req('POST', '/announce', payload),
withdraw: prefixes => req('POST', '/withdraw', { prefixes }), withdraw: prefixes => req('POST', '/withdraw', { prefixes }),
withdrawAll: () => req('POST', '/withdraw/all'), withdrawAll: () => req('POST', '/withdraw/all'),
fullTableStart: (count, batchSize) => req('POST', '/full-table/start', { count, batch_size: batchSize }),
fullTableStatus: () => req('GET', '/full-table/status'),
fullTableStop: () => req('POST', '/full-table/stop'),
} }

View File

@ -1,477 +0,0 @@
<template>
<div class="full-table">
<h2 class="section-title">Full Table Injection</h2>
<p class="section-desc">
Inject a realistic IPv4 routing table into ExaBGP for stress testing.
Routes are generated with varied AS paths, prefix lengths, and communities matching real DFZ distribution.
</p>
<div class="config-card">
<!-- Level selector -->
<div class="form-group">
<label>Table Size</label>
<div class="level-grid">
<button
v-for="level in levels"
:key="level.count"
class="level-btn"
:class="{ selected: selectedCount === level.count }"
:disabled="injecting"
@click="selectedCount = level.count"
>
<span class="level-count">{{ level.label }}</span>
<span class="level-desc">{{ level.desc }}</span>
</button>
</div>
</div>
<!-- Custom count -->
<div class="form-group">
<label>Custom Count</label>
<input
v-model.number="selectedCount"
type="number"
min="100"
max="950000"
step="1000"
:disabled="injecting"
class="mono-input"
/>
</div>
<!-- Action buttons -->
<div class="action-row">
<button v-if="!injecting" class="btn-start" @click="startInjection" :disabled="!selectedCount">
<span>&#9654;</span> Inject {{ formatNum(selectedCount) }} Routes
</button>
<button v-else class="btn-stop" @click="stopInjection">
<span>&#9632;</span> Stop Injection
</button>
<button
v-if="!injecting && lastCompleted"
class="btn-withdraw"
@click="withdrawAll"
:disabled="withdrawing"
>
{{ withdrawing ? 'Withdrawing...' : 'Withdraw All' }}
</button>
</div>
</div>
<!-- Status display -->
<div v-if="injecting || statusMsg" class="status-card">
<div class="status-header">
<span class="status-dot" :class="injecting ? 'dot-active' : 'dot-idle'"></span>
<span class="status-text">{{ statusMsg || 'Idle' }}</span>
</div>
<!-- Progress bar -->
<div v-if="state.total > 0" class="progress-section">
<div class="progress-labels">
<span>{{ formatNum(state.injected) }} / {{ formatNum(state.total) }}</span>
<span>{{ state.progress_pct || 0 }}%</span>
</div>
<div class="progress-track">
<div class="progress-fill" :style="{ width: (state.progress_pct || 0) + '%' }"></div>
</div>
</div>
<!-- Stats row -->
<div v-if="state.total > 0" class="stats-row">
<div class="stat-item">
<span class="stat-label">Rate</span>
<span class="stat-val">{{ formatNum(state.rate_pps || 0) }}/s</span>
</div>
<div class="stat-item">
<span class="stat-label">Elapsed</span>
<span class="stat-val">{{ state.elapsed_sec || 0 }}s</span>
</div>
<div class="stat-item">
<span class="stat-label">Active Routes</span>
<span class="stat-val">{{ formatNum(state.active_routes || 0) }}</span>
</div>
</div>
<!-- Error -->
<div v-if="state.error" class="inject-error">{{ state.error }}</div>
</div>
</div>
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
import { api } from '../api.js'
const emit = defineEmits(['routes-changed'])
const levels = [
{ count: 1000, label: '1K', desc: 'Quick test' },
{ count: 10000, label: '10K', desc: 'Light load' },
{ count: 50000, label: '50K', desc: 'Medium load' },
{ count: 100000, label: '100K', desc: 'Stress test' },
{ count: 500000, label: '500K', desc: 'Heavy load' },
{ count: 900000, label: '900K', desc: 'Full DFZ' },
]
const selectedCount = ref(10000)
const injecting = ref(false)
const statusMsg = ref('')
const lastCompleted = ref(false)
const withdrawing = ref(false)
const state = ref({})
let pollTimer = null
function formatNum(n) {
if (n == null) return '0'
return Number(n).toLocaleString()
}
async function startInjection() {
try {
statusMsg.value = 'Starting injection...'
injecting.value = true
lastCompleted.value = false
state.value = {}
await api.fullTableStart(selectedCount.value, 1000)
startPolling()
} catch (e) {
statusMsg.value = `Start failed: ${e.message}`
injecting.value = false
}
}
async function stopInjection() {
try {
await api.fullTableStop()
statusMsg.value = 'Stop requested...'
} catch (e) {
statusMsg.value = `Stop failed: ${e.message}`
}
}
async function withdrawAll() {
withdrawing.value = true
try {
const data = await api.withdrawAll()
statusMsg.value = `Withdrew ${data.count} routes`
lastCompleted.value = false
state.value = {}
emit('routes-changed')
} catch (e) {
statusMsg.value = `Withdraw failed: ${e.message}`
} finally {
withdrawing.value = false
}
}
function startPolling() {
stopPolling()
pollStatus()
pollTimer = setInterval(pollStatus, 2000)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function pollStatus() {
try {
const data = await api.fullTableStatus()
state.value = data
if (data.active) {
statusMsg.value = `Injecting: ${formatNum(data.injected)} / ${formatNum(data.total)} (${data.rate_pps || 0}/s)`
} else if (data.error) {
statusMsg.value = `Error: ${data.error}`
injecting.value = false
stopPolling()
} else if (data.injected > 0) {
statusMsg.value = `Complete: ${formatNum(data.injected)} routes in ${data.elapsed_sec}s (${data.rate_pps}/s)`
injecting.value = false
lastCompleted.value = true
stopPolling()
emit('routes-changed')
}
} catch (e) {
// keep polling
}
}
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped>
.full-table {
display: flex;
flex-direction: column;
gap: 18px;
max-width: 680px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.section-desc {
color: var(--muted);
font-size: 13px;
line-height: 1.6;
margin-top: -8px;
}
.config-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
display: flex;
flex-direction: column;
gap: 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.level-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.level-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 12px 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
transition: all 0.15s;
}
.level-btn:hover:not(:disabled) {
border-color: var(--accent);
background: rgba(79, 156, 249, 0.08);
}
.level-btn.selected {
border-color: var(--accent);
background: rgba(79, 156, 249, 0.15);
box-shadow: 0 0 0 1px var(--accent);
}
.level-count {
font-size: 18px;
font-weight: 700;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
color: var(--accent);
}
.level-desc {
font-size: 11px;
color: var(--muted);
}
.mono-input {
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 10px;
font-size: 13px;
outline: none;
max-width: 200px;
}
.mono-input:focus {
border-color: var(--accent);
}
.action-row {
display: flex;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--border);
}
.btn-start {
padding: 9px 22px;
background: rgba(72, 187, 120, 0.15);
color: #48bb78;
border: 1px solid rgba(72, 187, 120, 0.3);
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
gap: 7px;
}
.btn-start:hover:not(:disabled) {
background: rgba(72, 187, 120, 0.25);
}
.btn-stop {
padding: 9px 22px;
background: rgba(252, 129, 129, 0.15);
color: #fc8181;
border: 1px solid rgba(252, 129, 129, 0.3);
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
gap: 7px;
animation: pulse-border 1.5s ease-in-out infinite;
}
.btn-stop:hover {
background: rgba(252, 129, 129, 0.25);
}
.btn-withdraw {
padding: 9px 18px;
background: rgba(246, 173, 85, 0.15);
color: #f6ad55;
border: 1px solid rgba(246, 173, 85, 0.3);
font-weight: 600;
font-size: 13px;
}
.btn-withdraw:hover:not(:disabled) {
background: rgba(246, 173, 85, 0.25);
}
.status-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
.status-header {
display: flex;
align-items: center;
gap: 10px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-active {
background: #48bb78;
box-shadow: 0 0 8px #48bb78;
animation: pulse-dot 1s ease-in-out infinite;
}
.dot-idle {
background: var(--muted);
}
.status-text {
font-size: 14px;
font-weight: 600;
color: var(--text);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
}
.progress-section {
display: flex;
flex-direction: column;
gap: 5px;
}
.progress-labels {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--muted);
}
.progress-track {
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.5s ease;
}
.stats-row {
display: flex;
gap: 24px;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-label {
font-size: 10px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-val {
font-size: 15px;
font-weight: 700;
color: var(--text);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
}
.inject-error {
font-size: 12px;
color: #fc8181;
padding: 6px 10px;
background: rgba(252, 129, 129, 0.08);
border-radius: 4px;
border: 1px solid rgba(252, 129, 129, 0.2);
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes pulse-border {
0%, 100% { border-color: rgba(252, 129, 129, 0.3); }
50% { border-color: rgba(252, 129, 129, 0.6); }
}
</style>

View File

@ -2,7 +2,6 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
export default defineConfig({ export default defineConfig({
base: '/exabgp/',
plugins: [vue()], plugins: [vue()],
server: { server: {
proxy: { proxy: {

View File

@ -48,16 +48,12 @@ peer_states = {}
# ExaBGP command helpers # ExaBGP command helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_quiet_mode = False
def _send(cmd: str): def _send(cmd: str):
"""Write a command to ExaBGP via stdout.""" """Write a command to ExaBGP via stdout."""
with _stdout_lock: with _stdout_lock:
sys.stdout.write(cmd + '\n') sys.stdout.write(cmd + '\n')
sys.stdout.flush() sys.stdout.flush()
if not _quiet_mode: log.info('→ ExaBGP: %s', cmd)
log.info('→ ExaBGP: %s', cmd)
def _build_announce(prefix, next_hop='self', as_path=None, communities=None, med=None, local_pref=None): def _build_announce(prefix, next_hop='self', as_path=None, communities=None, med=None, local_pref=None):
@ -166,22 +162,7 @@ def api_withdraw_all():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
sys.path.insert(0, '/exabgp') sys.path.insert(0, '/exabgp')
from scenarios import SCENARIOS, generate_full_internet from scenarios import SCENARIOS
# ---------------------------------------------------------------------------
# Full-table background injection
# ---------------------------------------------------------------------------
_injection_state = {
'active': False,
'total': 0,
'injected': 0,
'elapsed_sec': 0,
'rate_pps': 0,
'error': None,
'stop_requested': False,
}
_injection_lock = threading.Lock()
@app.route('/scenarios', methods=['GET']) @app.route('/scenarios', methods=['GET'])
@ -242,131 +223,6 @@ def get_peers():
return jsonify({'peers': peer_states}) return jsonify({'peers': peer_states})
# ---------------------------------------------------------------------------
# Full-table injection endpoints
# ---------------------------------------------------------------------------
def _injection_worker(count, batch_size):
"""Background thread: generate and inject full internet table."""
global _quiet_mode
try:
_quiet_mode = True # suppress per-route logging
log.info('Generating %d full-table prefixes...', count)
routes = generate_full_internet(count)
with _injection_lock:
_injection_state['total'] = len(routes)
log.info('Generated %d routes, starting injection at batch_size=%d', len(routes), batch_size)
start_time = time.time()
for i, route in enumerate(routes):
with _injection_lock:
if _injection_state['stop_requested']:
log.info('Injection stopped by user at %d/%d', i, len(routes))
break
prefix = route['prefix']
announce_route(
prefix,
next_hop=route.get('next_hop', 'self'),
as_path=route.get('as_path', []),
communities=route.get('communities', []),
med=route.get('med'),
local_pref=route.get('local_pref'),
)
# Update progress periodically (every batch_size routes)
if (i + 1) % batch_size == 0:
elapsed = time.time() - start_time
with _injection_lock:
_injection_state['injected'] = i + 1
_injection_state['elapsed_sec'] = round(elapsed, 1)
_injection_state['rate_pps'] = round((i + 1) / elapsed, 1) if elapsed > 0 else 0
log.info('Injection progress: %d/%d (%.0f/s)',
i + 1, len(routes), (i + 1) / elapsed if elapsed > 0 else 0)
elapsed = time.time() - start_time
with _injection_lock:
_injection_state['injected'] = min(i + 1, len(routes))
_injection_state['elapsed_sec'] = round(elapsed, 1)
_injection_state['rate_pps'] = round(_injection_state['injected'] / elapsed, 1) if elapsed > 0 else 0
_injection_state['active'] = False
log.info('Injection complete: %d routes in %.1fs (%.0f/s)',
_injection_state['injected'], elapsed,
_injection_state['injected'] / elapsed if elapsed > 0 else 0)
except Exception as e:
log.error('Injection error: %s', e)
with _injection_lock:
_injection_state['error'] = str(e)
_injection_state['active'] = False
finally:
_quiet_mode = False
@app.route('/full-table/start', methods=['POST'])
def start_full_table():
"""Start background injection of a full IPv4 routing table.
POST body (all optional):
count: Number of prefixes (default 900000)
batch_size: Progress update interval (default 1000)
"""
with _injection_lock:
if _injection_state['active']:
return jsonify({
'error': 'Injection already in progress',
'state': dict(_injection_state),
}), 409
data = request.get_json(force=True) if request.data else {}
count = int(data.get('count', 900000))
batch_size = int(data.get('batch_size', 1000))
with _injection_lock:
_injection_state.update({
'active': True,
'total': count,
'injected': 0,
'elapsed_sec': 0,
'rate_pps': 0,
'error': None,
'stop_requested': False,
})
t = threading.Thread(target=_injection_worker, args=(count, batch_size), daemon=True)
t.start()
log.info('Started full-table injection: %d prefixes', count)
return jsonify({
'status': 'started',
'count': count,
'message': f'Generating and injecting {count} prefixes in background. GET /full-table/status to track progress.',
})
@app.route('/full-table/status', methods=['GET'])
def full_table_status():
"""Get current full-table injection progress."""
with _injection_lock:
state = dict(_injection_state)
if state['total'] > 0:
state['progress_pct'] = round(state['injected'] / state['total'] * 100, 1)
else:
state['progress_pct'] = 0
state['active_routes'] = len(active_routes)
return jsonify(state)
@app.route('/full-table/stop', methods=['POST'])
def stop_full_table():
"""Stop an in-progress full-table injection."""
with _injection_lock:
if not _injection_state['active']:
return jsonify({'error': 'No injection in progress'}), 400
_injection_state['stop_requested'] = True
return jsonify({'status': 'stop_requested', 'injected_so_far': _injection_state['injected']})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ExaBGP event loop (main thread) # ExaBGP event loop (main thread)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -12,9 +12,6 @@ Usage:
inject.py withdraw-all inject.py withdraw-all
inject.py scenario <name> inject.py scenario <name>
inject.py withdraw-scenario <name> inject.py withdraw-scenario <name>
inject.py full-table [--count N] [--follow] # inject full IPv4 table (background)
inject.py full-table-status # show injection progress
inject.py full-table-stop # stop injection
inject.py churn [--count N] [--interval SEC] # cycle announce/withdraw for ip_rib_log population inject.py churn [--count N] [--interval SEC] # cycle announce/withdraw for ip_rib_log population
inject.py monitor # live-refresh terminal view inject.py monitor # live-refresh terminal view
@ -32,8 +29,8 @@ import requests
API = os.environ.get('EXABGP_API', 'http://localhost:5050') API = os.environ.get('EXABGP_API', 'http://localhost:5050')
def _post(path, data=None, timeout=10): def _post(path, data=None):
r = requests.post(f'{API}{path}', json=data or {}, timeout=timeout) r = requests.post(f'{API}{path}', json=data or {}, timeout=10)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@ -177,101 +174,6 @@ def cmd_withdraw_scenario(args):
print(f"Withdrew scenario '{args.name}': {data['count']} routes withdrawn") print(f"Withdrew scenario '{args.name}': {data['count']} routes withdrawn")
def cmd_full_table(args):
"""Inject a full IPv4 routing table for stress testing."""
count = args.count
print(f"Starting full-table injection: {count} prefixes")
print("This generates routes in background. Use 'inject.py full-table-status' to track.\n")
data = _post('/full-table/start', {'count': count, 'batch_size': args.batch_size}, timeout=120)
print(data.get('message', 'Started'))
if args.follow:
print()
_follow_injection()
def cmd_full_table_status(args):
"""Show full-table injection progress."""
data = _get('/full-table/status')
active = data.get('active', False)
total = data.get('total', 0)
injected = data.get('injected', 0)
pct = data.get('progress_pct', 0)
rate = data.get('rate_pps', 0)
elapsed = data.get('elapsed_sec', 0)
error = data.get('error')
active_routes = data.get('active_routes', 0)
if error:
print(f"ERROR: {error}")
elif active:
bar_len = 40
filled = int(bar_len * pct / 100)
bar = '#' * filled + '-' * (bar_len - filled)
print(f"[{bar}] {pct:.1f}%")
print(f" Injected: {injected:,} / {total:,} ({rate:.0f} routes/s)")
print(f" Elapsed: {elapsed:.0f}s")
print(f" Active routes in ExaBGP: {active_routes:,}")
elif total > 0:
print(f"Injection complete: {injected:,} / {total:,} routes in {elapsed:.0f}s ({rate:.0f}/s)")
print(f"Active routes in ExaBGP: {active_routes:,}")
else:
print("No injection running or completed.")
print(f"Active routes: {active_routes:,}")
def cmd_full_table_stop(args):
"""Stop an in-progress full-table injection."""
try:
data = _post('/full-table/stop')
print(f"Stop requested. Injected so far: {data.get('injected_so_far', '?'):,}")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 400:
print("No injection in progress.")
else:
raise
def _follow_injection():
"""Poll injection status until complete."""
import shutil
lines_printed = 0
try:
while True:
data = _get('/full-table/status')
active = data.get('active', False)
total = data.get('total', 0)
injected = data.get('injected', 0)
pct = data.get('progress_pct', 0)
rate = data.get('rate_pps', 0)
elapsed = data.get('elapsed_sec', 0)
active_routes = data.get('active_routes', 0)
# Move cursor up to overwrite
if lines_printed > 0:
print(f"\033[{lines_printed}A", end='')
bar_len = 40
filled = int(bar_len * pct / 100)
bar = '#' * filled + '-' * (bar_len - filled)
output_lines = [
f" [{bar}] {pct:.1f}%",
f" Injected: {injected:,} / {total:,} ({rate:.0f} routes/s) elapsed: {elapsed:.0f}s",
f" Active routes: {active_routes:,}",
]
print('\n'.join(output_lines))
lines_printed = len(output_lines)
if not active:
print(f"\nDone! {injected:,} routes injected in {elapsed:.0f}s")
break
time.sleep(2)
except KeyboardInterrupt:
print("\n\nFollowing stopped (injection continues in background).")
def cmd_churn(args): def cmd_churn(args):
""" """
Cycle announce/withdraw on the 'churn' scenario to generate ip_rib_log Cycle announce/withdraw on the 'churn' scenario to generate ip_rib_log
@ -334,17 +236,6 @@ def main():
p = sub.add_parser('withdraw-scenario', help='Withdraw a named scenario') p = sub.add_parser('withdraw-scenario', help='Withdraw a named scenario')
p.add_argument('name') p.add_argument('name')
p = sub.add_parser('full-table', help='Inject full IPv4 routing table (background)')
p.add_argument('--count', type=int, default=900000, metavar='N',
help='Number of prefixes to inject (default: 900000)')
p.add_argument('--batch-size', type=int, default=1000, metavar='N',
help='Progress update interval (default: 1000)')
p.add_argument('--follow', '-f', action='store_true',
help='Follow progress until complete')
sub.add_parser('full-table-status', help='Show full-table injection progress')
sub.add_parser('full-table-stop', help='Stop full-table injection')
p = sub.add_parser('churn', help='Cycle announce/withdraw to populate ip_rib_log') p = sub.add_parser('churn', help='Cycle announce/withdraw to populate ip_rib_log')
p.add_argument('--count', type=int, default=0, metavar='N', p.add_argument('--count', type=int, default=0, metavar='N',
help='Number of cycles (0 = infinite)') help='Number of cycles (0 = infinite)')
@ -364,9 +255,6 @@ def main():
'withdraw-all': cmd_withdraw_all, 'withdraw-all': cmd_withdraw_all,
'scenario': cmd_scenario, 'scenario': cmd_scenario,
'withdraw-scenario': cmd_withdraw_scenario, 'withdraw-scenario': cmd_withdraw_scenario,
'full-table': cmd_full_table,
'full-table-status': cmd_full_table_status,
'full-table-stop': cmd_full_table_stop,
'churn': cmd_churn, 'churn': cmd_churn,
} }

View File

@ -1,658 +0,0 @@
#!/usr/bin/env python3
"""
Route Diversity Configuration Script
=====================================
Adds loopbacks, static routes, route-policies, and BGP redistribution
to R9K-01 through R9K-07 to create locally-originated routes that
produce meaningful RR Loc-RIB diffs between CORE-01 and CORE-02.
IS-IS topology (natural asymmetry no metric tuning needed):
CORE-01 R9K-01, R9K-02, R9K-03, R9K-04, R9K-05
CORE-02 R9K-05, R9K-06, R9K-07
R9K-04 R9K-06 (cross-link)
R9K-05 dual-homed to both COREs
Overlapping prefixes from CORE-01-side and CORE-02-side routers produce
next-hop diffs because each RR picks the client with lowest IGP cost.
Address plan:
Loopbacks: 10.110.{router_id}.{1,2}/32 (unique per router)
Overlap LBs: 10.110.{100-103}.1/32 (shared across router pairs)
Static routes: 10.111.{router_id}.0/24 (unique per router)
Overlap statics: 10.111.{100-103}.0/24 (shared across router pairs)
Usage:
python3 route_diversity_config.py # apply all config
python3 route_diversity_config.py --verify-only # just check current state
python3 route_diversity_config.py --rollback # remove all added config
"""
from ncclient import manager
import xml.etree.ElementTree as ET
import sys
import argparse
# YANG namespaces
IFMGR_NS = 'http://cisco.com/ns/yang/Cisco-IOS-XR-ifmgr-cfg'
IPV4IO_NS = 'http://cisco.com/ns/yang/Cisco-IOS-XR-ipv4-io-cfg'
STATIC_NS = 'http://cisco.com/ns/yang/Cisco-IOS-XR-ip-static-cfg'
BGP_NS = 'http://cisco.com/ns/yang/Cisco-IOS-XR-ipv4-bgp-cfg'
ISIS_NS = 'http://cisco.com/ns/yang/Cisco-IOS-XR-clns-isis-cfg'
RPL_NS = 'http://cisco.com/ns/yang/Cisco-IOS-XR-policy-repository-cfg'
# ──────────────────────────────────────────────────────────────────────
# Router definitions
# ──────────────────────────────────────────────────────────────────────
ROUTERS = {
'R9K-01': {
'mgmt': '10.100.0.1',
'loopbacks': [
('Loopback10', '10.110.1.1', '255.255.255.255'),
('Loopback11', '10.110.1.2', '255.255.255.255'),
('Loopback100', '10.110.100.1', '255.255.255.255'), # overlap with R9K-06
('Loopback103', '10.110.103.1', '255.255.255.255'), # overlap with R9K-04, R9K-07
],
'statics': [
('10.111.1.0', 24, 100), # unique, tag=100 → LP=200
('10.111.100.0', 24, 100), # overlap with R9K-06 (tag=200)
('10.111.103.0', 24, 100), # overlap with R9K-04, R9K-07 (same tag)
],
},
'R9K-02': {
'mgmt': '10.100.0.2',
'loopbacks': [
('Loopback10', '10.110.2.1', '255.255.255.255'),
('Loopback11', '10.110.2.2', '255.255.255.255'),
('Loopback101', '10.110.101.1', '255.255.255.255'), # overlap with R9K-07
],
'statics': [
('10.111.2.0', 24, 100),
('10.111.101.0', 24, 100), # overlap with R9K-07 (tag=300)
],
},
'R9K-03': {
'mgmt': '10.100.0.3',
'loopbacks': [
('Loopback10', '10.110.3.1', '255.255.255.255'),
('Loopback11', '10.110.3.2', '255.255.255.255'),
('Loopback102', '10.110.102.1', '255.255.255.255'), # overlap with R9K-05
],
'statics': [
('10.111.3.0', 24, 100),
('10.111.102.0', 24, 100), # overlap with R9K-04 (tag=200)
],
},
'R9K-04': {
'mgmt': '10.100.0.4',
'loopbacks': [
('Loopback10', '10.110.4.1', '255.255.255.255'),
('Loopback11', '10.110.4.2', '255.255.255.255'),
('Loopback103', '10.110.103.1', '255.255.255.255'), # overlap with R9K-01, R9K-07
],
'statics': [
('10.111.4.0', 24, 200), # tag=200 → LP=150
('10.111.102.0', 24, 200), # overlap with R9K-03 (tag=100)
('10.111.103.0', 24, 100), # overlap with R9K-01, R9K-07 (same tag)
],
},
'R9K-05': {
'mgmt': '10.100.0.5',
'loopbacks': [
('Loopback10', '10.110.5.1', '255.255.255.255'),
('Loopback11', '10.110.5.2', '255.255.255.255'),
('Loopback102', '10.110.102.1', '255.255.255.255'), # overlap with R9K-03
],
'statics': [
('10.111.5.0', 24, 100),
],
},
'R9K-06': {
'mgmt': '10.100.0.6',
'loopbacks': [
('Loopback10', '10.110.6.1', '255.255.255.255'),
('Loopback11', '10.110.6.2', '255.255.255.255'),
('Loopback100', '10.110.100.1', '255.255.255.255'), # overlap with R9K-01
],
'statics': [
('10.111.6.0', 24, 200), # tag=200 → LP=150
('10.111.100.0', 24, 200), # overlap with R9K-01 (tag=100)
],
},
'R9K-07': {
'mgmt': '10.100.0.7',
'loopbacks': [
('Loopback10', '10.110.7.1', '255.255.255.255'),
('Loopback11', '10.110.7.2', '255.255.255.255'),
('Loopback101', '10.110.101.1', '255.255.255.255'), # overlap with R9K-02
('Loopback103', '10.110.103.1', '255.255.255.255'), # overlap with R9K-01, R9K-04
],
'statics': [
('10.111.7.0', 24, 300), # tag=300 → LP=100
('10.111.101.0', 24, 300), # overlap with R9K-02 (tag=100)
('10.111.103.0', 24, 100), # overlap with R9K-01, R9K-04 (same tag)
],
},
}
# ──────────────────────────────────────────────────────────────────────
# Route-policy (RPL text blob)
# ──────────────────────────────────────────────────────────────────────
ROUTE_POLICY_NAME = 'REDIST-TO-BGP'
ROUTE_POLICY_BODY = """\
route-policy REDIST-TO-BGP
if tag is 100 then
set local-preference 200
set med 50
set community (65020:100) additive
pass
elseif tag is 200 then
set local-preference 150
set med 100
set community (65020:200) additive
pass
elseif tag is 300 then
set local-preference 100
set med 200
set community (65020:300) additive
pass
else
set local-preference 100
pass
endif
end-policy
"""
# ──────────────────────────────────────────────────────────────────────
# XML builders
# ──────────────────────────────────────────────────────────────────────
def loopback_xml(name, addr, mask):
"""Create a loopback interface with an IPv4 address."""
return f"""
<config>
<interface-configurations xmlns="{IFMGR_NS}">
<interface-configuration>
<active>act</active>
<interface-name>{name}</interface-name>
<interface-virtual/>
<ipv4-network xmlns="{IPV4IO_NS}">
<addresses>
<primary>
<address>{addr}</address>
<netmask>{mask}</netmask>
</primary>
</addresses>
</ipv4-network>
</interface-configuration>
</interface-configurations>
</config>
"""
def static_route_xml(prefix, prefix_len, tag):
"""Create a static route to Null0 with a tag."""
return f"""
<config>
<router-static xmlns="{STATIC_NS}">
<default-vrf>
<address-family>
<vrfipv4>
<vrf-unicast>
<vrf-prefixes>
<vrf-prefix>
<prefix>{prefix}</prefix>
<prefix-length>{prefix_len}</prefix-length>
<vrf-route>
<vrf-next-hop-table>
<vrf-next-hop-interface-name>
<interface-name>Null0</interface-name>
<tag>{tag}</tag>
</vrf-next-hop-interface-name>
</vrf-next-hop-table>
</vrf-route>
</vrf-prefix>
</vrf-prefixes>
</vrf-unicast>
</vrfipv4>
</address-family>
</default-vrf>
</router-static>
</config>
"""
def route_policy_xml(name, body):
"""Create/replace a route-policy (RPL text blob)."""
return f"""
<config>
<routing-policy xmlns="{RPL_NS}">
<route-policies>
<route-policy>
<route-policy-name>{name}</route-policy-name>
<rpl-route-policy>{body}</rpl-route-policy>
</route-policy>
</route-policies>
</routing-policy>
</config>
"""
def isis_passive_xml(intf_name):
"""Add a loopback to IS-IS instance 1 (passive by default for loopbacks)."""
return f"""
<config>
<isis xmlns="{ISIS_NS}">
<instances>
<instance>
<instance-name>1</instance-name>
<interfaces>
<interface>
<interface-name>{intf_name}</interface-name>
<running/>
<interface-afs>
<interface-af>
<af-name>ipv4</af-name>
<saf-name>unicast</saf-name>
<interface-af-data/>
</interface-af>
</interface-afs>
</interface>
</interfaces>
</instance>
</instances>
</isis>
</config>
"""
def bgp_redistribute_xml():
"""Configure redistribute connected + static with REDIST-TO-BGP policy."""
return f"""
<config>
<bgp xmlns="{BGP_NS}">
<instance>
<instance-name>default</instance-name>
<instance-as>
<as>0</as>
<four-byte-as>
<as>65020</as>
<bgp-running/>
<default-vrf>
<global>
<global-afs>
<global-af>
<af-name>ipv4-unicast</af-name>
<enable/>
<connected-routes>
<route-policy-name>{ROUTE_POLICY_NAME}</route-policy-name>
</connected-routes>
<static-routes>
<route-policy-name>{ROUTE_POLICY_NAME}</route-policy-name>
</static-routes>
</global-af>
</global-afs>
</global>
</default-vrf>
</four-byte-as>
</instance-as>
</instance>
</bgp>
</config>
"""
# ──────────────────────────────────────────────────────────────────────
# Rollback XML builders (delete operations)
# ──────────────────────────────────────────────────────────────────────
NC_NS = 'urn:ietf:params:xml:ns:netconf:base:1.0'
def delete_loopback_xml(name):
return f"""
<config>
<interface-configurations xmlns="{IFMGR_NS}">
<interface-configuration xmlns:nc="{NC_NS}" nc:operation="delete">
<active>act</active>
<interface-name>{name}</interface-name>
</interface-configuration>
</interface-configurations>
</config>
"""
def delete_static_route_xml(prefix, prefix_len):
return f"""
<config>
<router-static xmlns="{STATIC_NS}">
<default-vrf>
<address-family>
<vrfipv4>
<vrf-unicast>
<vrf-prefixes>
<vrf-prefix xmlns:nc="{NC_NS}" nc:operation="delete">
<prefix>{prefix}</prefix>
<prefix-length>{prefix_len}</prefix-length>
</vrf-prefix>
</vrf-prefixes>
</vrf-unicast>
</vrfipv4>
</address-family>
</default-vrf>
</router-static>
</config>
"""
def delete_bgp_redistribute_xml():
return f"""
<config>
<bgp xmlns="{BGP_NS}">
<instance>
<instance-name>default</instance-name>
<instance-as>
<as>0</as>
<four-byte-as>
<as>65020</as>
<bgp-running/>
<default-vrf>
<global>
<global-afs>
<global-af>
<af-name>ipv4-unicast</af-name>
<enable/>
<connected-routes xmlns:nc="{NC_NS}" nc:operation="delete"/>
<static-routes xmlns:nc="{NC_NS}" nc:operation="delete"/>
</global-af>
</global-afs>
</global>
</default-vrf>
</four-byte-as>
</instance-as>
</instance>
</bgp>
</config>
"""
def delete_isis_interface_xml(intf_name):
return f"""
<config>
<isis xmlns="{ISIS_NS}">
<instances>
<instance>
<instance-name>1</instance-name>
<interfaces>
<interface xmlns:nc="{NC_NS}" nc:operation="delete">
<interface-name>{intf_name}</interface-name>
</interface>
</interfaces>
</instance>
</instances>
</isis>
</config>
"""
def delete_route_policy_xml(name):
return f"""
<config>
<routing-policy xmlns="{RPL_NS}">
<route-policies>
<route-policy xmlns:nc="{NC_NS}" nc:operation="delete">
<route-policy-name>{name}</route-policy-name>
</route-policy>
</route-policies>
</routing-policy>
</config>
"""
# ──────────────────────────────────────────────────────────────────────
# Configuration functions
# ──────────────────────────────────────────────────────────────────────
def nc_connect(mgmt_ip):
"""Open NETCONF session."""
return manager.connect(
host=mgmt_ip,
port=830,
username='webui',
password='cisco',
hostkey_verify=False,
device_params={'name': 'iosxr'},
timeout=30,
)
def configure_router(label, cfg):
"""Apply full route-diversity config to a single router."""
mgmt_ip = cfg['mgmt']
print(f"\n{''*60}")
print(f" Configuring {label} ({mgmt_ip})")
print(f"{''*60}")
lb_names = [lb[0] for lb in cfg['loopbacks']]
static_prefixes = [f"{s[0]}/{s[1]}" for s in cfg['statics']]
print(f" Loopbacks: {', '.join(lb_names)}")
print(f" Statics: {', '.join(static_prefixes)}")
try:
with nc_connect(mgmt_ip) as m:
# Phase 1: Route-policy (must exist before BGP references it)
print(f" → Creating route-policy {ROUTE_POLICY_NAME}...")
m.edit_config(target='candidate', config=route_policy_xml(ROUTE_POLICY_NAME, ROUTE_POLICY_BODY))
# Phase 2: Loopback interfaces
for name, addr, mask in cfg['loopbacks']:
print(f" → Creating {name} ({addr})...")
m.edit_config(target='candidate', config=loopback_xml(name, addr, mask))
# Phase 3: Static routes
for prefix, plen, tag in cfg['statics']:
print(f" → Static {prefix}/{plen} → Null0 tag={tag}...")
m.edit_config(target='candidate', config=static_route_xml(prefix, plen, tag))
# Phase 4: IS-IS passive on new loopbacks
for name, _, _ in cfg['loopbacks']:
print(f" → IS-IS passive: {name}...")
m.edit_config(target='candidate', config=isis_passive_xml(name))
# Phase 5: BGP redistribution
print(f" → BGP redistribute connected + static...")
m.edit_config(target='candidate', config=bgp_redistribute_xml())
# Phase 6: Commit
print(f" → Committing...")
m.commit()
print(f"{label} done.")
return True
except Exception as e:
print(f" ✗ ERROR on {label}: {e}")
return False
def rollback_router(label, cfg):
"""Remove all route-diversity config from a single router."""
mgmt_ip = cfg['mgmt']
print(f"\n{''*60}")
print(f" Rolling back {label} ({mgmt_ip})")
print(f"{''*60}")
try:
with nc_connect(mgmt_ip) as m:
# Remove BGP redistribution first (references the policy)
print(f" → Removing BGP redistribute...")
try:
m.edit_config(target='candidate', config=delete_bgp_redistribute_xml())
except Exception as e:
print(f" (skip — may not exist: {e})")
# Remove IS-IS interfaces
for name, _, _ in cfg['loopbacks']:
print(f" → Removing IS-IS interface {name}...")
try:
m.edit_config(target='candidate', config=delete_isis_interface_xml(name))
except Exception as e:
print(f" (skip: {e})")
# Remove static routes
for prefix, plen, _ in cfg['statics']:
print(f" → Removing static {prefix}/{plen}...")
try:
m.edit_config(target='candidate', config=delete_static_route_xml(prefix, plen))
except Exception as e:
print(f" (skip: {e})")
# Remove loopbacks
for name, _, _ in cfg['loopbacks']:
print(f" → Removing {name}...")
try:
m.edit_config(target='candidate', config=delete_loopback_xml(name))
except Exception as e:
print(f" (skip: {e})")
# Remove route-policy
print(f" → Removing route-policy {ROUTE_POLICY_NAME}...")
try:
m.edit_config(target='candidate', config=delete_route_policy_xml(ROUTE_POLICY_NAME))
except Exception as e:
print(f" (skip: {e})")
print(f" → Committing rollback...")
m.commit()
print(f"{label} rolled back.")
return True
except Exception as e:
print(f" ✗ ERROR rolling back {label}: {e}")
return False
def verify_router(label, cfg):
"""Check if route-diversity config is present on a router."""
mgmt_ip = cfg['mgmt']
try:
with nc_connect(mgmt_ip) as m:
# Check loopbacks
filt_intf = f"""<filter>
<interface-configurations xmlns="{IFMGR_NS}"/>
</filter>"""
r_intf = str(m.get_config(source='running', filter=filt_intf))
found_lbs = []
for name, _, _ in cfg['loopbacks']:
if name in r_intf:
found_lbs.append(name)
# Check route-policy
filt_rpl = f"""<filter>
<routing-policy xmlns="{RPL_NS}"/>
</filter>"""
r_rpl = str(m.get_config(source='running', filter=filt_rpl))
has_policy = ROUTE_POLICY_NAME in r_rpl
# Check BGP redistribute
filt_bgp = f"""<filter>
<bgp xmlns="{BGP_NS}">
<instance><instance-name>default</instance-name></instance>
</bgp>
</filter>"""
r_bgp = str(m.get_config(source='running', filter=filt_bgp))
has_redist_connected = 'connected-routes' in r_bgp and ROUTE_POLICY_NAME in r_bgp
has_redist_static = 'static-routes' in r_bgp and ROUTE_POLICY_NAME in r_bgp
total_lbs = len(cfg['loopbacks'])
lb_str = f"{len(found_lbs)}/{total_lbs}"
pol = '' if has_policy else ''
rc = '' if has_redist_connected else ''
rs = '' if has_redist_static else ''
ok = len(found_lbs) == total_lbs and has_policy and has_redist_connected and has_redist_static
status = 'OK' if ok else 'INCOMPLETE'
print(f" {label:8s} LBs={lb_str:5s} Policy={pol} Redist-C={rc} Redist-S={rs} [{status}]")
except Exception as e:
print(f" {label:8s} verify error: {e}")
# ──────────────────────────────────────────────────────────────────────
# Main
# ──────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description='Route Diversity Configuration for RR Diff Analysis')
parser.add_argument('--verify-only', action='store_true', help='Only verify current state')
parser.add_argument('--rollback', action='store_true', help='Remove all added config')
args = parser.parse_args()
print("Route Diversity Configuration Script")
print("=" * 60)
print(f"Targets: {len(ROUTERS)} routers ({', '.join(ROUTERS.keys())})")
print()
if args.verify_only:
print("Verify-only mode")
print('-' * 60)
print(f" {'Router':8s} {'LBs':5s} {'Policy':6s} {'Redist-C':8s} {'Redist-S':8s} Status")
for label, cfg in ROUTERS.items():
verify_router(label, cfg)
return
if args.rollback:
print("ROLLBACK mode — removing all route-diversity config")
print('-' * 60)
results = []
for label, cfg in ROUTERS.items():
ok = rollback_router(label, cfg)
results.append((label, ok))
failed = [l for l, ok in results if not ok]
print()
if failed:
print(f"FAILED rollback: {', '.join(failed)}")
sys.exit(1)
else:
print("All routers rolled back successfully.")
return
# Apply mode
results = []
for label, cfg in ROUTERS.items():
ok = configure_router(label, cfg)
results.append((label, ok))
# Post-apply verification
print(f"\n{'='*60}")
print("Post-apply verification")
print('=' * 60)
print(f" {'Router':8s} {'LBs':5s} {'Policy':6s} {'Redist-C':8s} {'Redist-S':8s} Status")
for label, cfg in ROUTERS.items():
verify_router(label, cfg)
failed = [l for l, ok in results if not ok]
print()
if failed:
print(f"FAILED: {', '.join(failed)}")
sys.exit(1)
else:
total_lbs = sum(len(c['loopbacks']) for c in ROUTERS.values())
total_statics = sum(len(c['statics']) for c in ROUTERS.values())
print(f"All routers configured successfully.")
print(f" {total_lbs} loopbacks + {total_statics} static routes created")
print()
print("Wait ~60s for BGP convergence and BMP collection, then verify:")
print()
print(" # Check new prefixes in OpenBMP")
print(" docker exec -i obmp-psql psql -U openbmp -d openbmp -c \\")
print(" \"SELECT prefix::text, COUNT(*) FROM ip_rib")
print(" WHERE (prefix::text LIKE '10.110.%' OR prefix::text LIKE '10.111.%')")
print(" AND iswithdrawn = false GROUP BY prefix ORDER BY prefix;\"")
if __name__ == '__main__':
main()

View File

@ -441,100 +441,6 @@ _PATH_DIVERSITY_ROUTES = [
# Registry # Registry
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Full Internet Table Generator
# Generates realistic-looking IPv4 prefixes across the routable address space
# with varied AS paths, prefix lengths, origins, and communities.
# Configurable count: 10K (quick test) to 900K+ (full table stress test).
# ---------------------------------------------------------------------------
# Well-known transit ASNs for realistic path construction
_TRANSIT_ASNS = [174, 701, 1299, 2914, 3257, 3356, 6461, 6762, 7018, 3491, 5400, 1239]
# Realistic origin ASNs (mix of large providers and small networks)
_ORIGIN_POOL = [
13335, 15169, 16509, 8075, 20940, 32934, 714, 54113, 13414, 7922,
36459, 46489, 14618, 16276, 24940, 47541, 35916, 49981, 9808, 4134,
4837, 9121, 12322, 3320, 6830, 5511, 1273, 6939, 4766, 9318,
23693, 38001, 45102, 58453, 10026, 18881, 28573, 7738, 26599, 8151,
11888, 17676, 4713, 7545, 9299, 50304, 51167, 60068, 41095, 34984,
]
# IANA-allocated first octets for routable IPv4 (subset for realism)
_ROUTABLE_FIRST_OCTETS = list(range(1, 56)) + list(range(57, 127)) + list(range(128, 224))
def generate_full_internet(count=900000):
"""Generate a realistic full IPv4 routing table.
Distributes prefixes across the IPv4 address space with realistic
prefix lengths (/8 through /24) and varied AS paths.
Args:
count: Number of prefixes to generate (default 900K).
Returns:
List of route dicts.
"""
import random
rng = random.Random(42) # deterministic for reproducibility
routes = []
generated = set()
# Prefix length distribution (approximates real DFZ):
# /24: ~55%, /23: ~8%, /22: ~7%, /21: ~5%, /20: ~5%,
# /19: ~4%, /18: ~3%, /17: ~2%, /16: ~5%, /15-/8: ~6%
prefix_len_weights = {
24: 55, 23: 8, 22: 7, 21: 5, 20: 5,
19: 4, 18: 3, 17: 2, 16: 5, 15: 2,
14: 1, 13: 1, 12: 1, 11: 0.5, 10: 0.3,
9: 0.1, 8: 0.1,
}
plen_choices = list(prefix_len_weights.keys())
plen_weights = list(prefix_len_weights.values())
# AS path length distribution: 1-hop: 5%, 2-hop: 30%, 3-hop: 40%, 4-hop: 20%, 5-hop: 5%
path_len_weights = [5, 30, 40, 20, 5]
while len(routes) < count:
# Pick a routable first octet weighted by allocation density
first = rng.choice(_ROUTABLE_FIRST_OCTETS)
plen = rng.choices(plen_choices, weights=plen_weights, k=1)[0]
# Generate random prefix within this /8
if plen <= 8:
prefix = f'{first}.0.0.0/{plen}'
elif plen <= 16:
second = rng.randint(0, 255) & (0xFF << (16 - plen))
prefix = f'{first}.{second}.0.0/{plen}'
elif plen <= 24:
second = rng.randint(0, 255)
third = rng.randint(0, 255) & (0xFF << (24 - plen))
prefix = f'{first}.{second}.{third}.0/{plen}'
else:
continue
if prefix in generated:
continue
generated.add(prefix)
# Build realistic AS path
path_len = rng.choices([1, 2, 3, 4, 5], weights=path_len_weights, k=1)[0]
origin = rng.choice(_ORIGIN_POOL) if rng.random() < 0.3 else (64512 + rng.randint(0, 65535 - 64512))
transits = rng.sample(_TRANSIT_ASNS, min(path_len - 1, len(_TRANSIT_ASNS)))
as_path = [65100] + transits[:path_len - 1] + [origin]
# Occasionally add communities (~20% of routes)
communities = []
if rng.random() < 0.2:
communities.append(f'65100:{rng.choice([100, 200, 300, 400, 500])}')
routes.append(_r(prefix, as_path, communities=communities or None))
return routes
SCENARIOS = { SCENARIOS = {
'internet_sample': { 'internet_sample': {
'description': 'Partial internet table (~80 IPv4 + 14 IPv6 prefixes with realistic AS paths)', 'description': 'Partial internet table (~80 IPv4 + 14 IPv6 prefixes with realistic AS paths)',

View File

@ -1,418 +0,0 @@
{
"annotations": {"list": [{"builtIn": 1,"datasource": {"type": "datasource","uid": "grafana"},"enable": true,"hide": true,"iconColor": "rgba(0, 211, 255, 1)","name": "Annotations & Alerts","target": {"limit": 100,"matchAny": false,"tags": [],"type": "dashboard"},"type": "dashboard"}]},
"description": "Compare Route Reflector Loc-RIB tables via BMP Adj-RIB-In. Surfaces missing prefixes, attribute differences, and per-client consistency between two RRs.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"datasource": {"type": "datasource","uid": "grafana"},
"gridPos": {"h": 5,"w": 24,"x": 0,"y": 0},
"id": 1,
"options": {
"content": "## Route Reflector Loc-RIB Diff\n\nCompares the RIB tables of two Route Reflectors via BMP. Select your two RRs using the **RR1** and **RR2** dropdowns above.\n\n**What this shows:**\n- **Summary stats** — prefix counts and diff totals\n- **Missing prefixes** — routes present on one RR but not the other (expected for each RR's own locally-originated routes)\n- **Attribute differences** — same prefix on both RRs but with different next-hop, AS path, or other attributes (indicates different best-path selection)\n- **Per-client consistency** — route counts each client sends to each RR (should be identical in a healthy RR cluster)",
"mode": "markdown"
},
"title": "RR Loc-RIB Diff — Overview",
"type": "text"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Total active (non-withdrawn) prefixes on RR1",
"fieldConfig": {
"defaults": {"color": {"fixedColor": "blue","mode": "fixed"},"thresholds": {"mode": "absolute","steps": [{"color": "blue","value": null}]}},
"overrides": []
},
"gridPos": {"h": 4,"w": 4,"x": 0,"y": 5},
"id": 10,
"options": {"colorMode": "value","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"textMode": "auto"},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT COUNT(DISTINCT r.prefix || '/' || r.prefix_len) AS \"prefixes\"\nFROM ip_rib r\nJOIN bgp_peers p ON p.hash_id = r.peer_hash_id\nJOIN routers rt ON rt.hash_id = p.router_hash_id\nWHERE rt.name = '$rr1' AND r.iswithdrawn = false",
"refId": "A"
}
],
"title": "$rr1 Prefixes",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Total active (non-withdrawn) prefixes on RR2",
"fieldConfig": {
"defaults": {"color": {"fixedColor": "green","mode": "fixed"},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null}]}},
"overrides": []
},
"gridPos": {"h": 4,"w": 4,"x": 4,"y": 5},
"id": 11,
"options": {"colorMode": "value","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"textMode": "auto"},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT COUNT(DISTINCT r.prefix || '/' || r.prefix_len) AS \"prefixes\"\nFROM ip_rib r\nJOIN bgp_peers p ON p.hash_id = r.peer_hash_id\nJOIN routers rt ON rt.hash_id = p.router_hash_id\nWHERE rt.name = '$rr2' AND r.iswithdrawn = false",
"refId": "A"
}
],
"title": "$rr2 Prefixes",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Prefixes present on RR1 but missing from RR2",
"fieldConfig": {
"defaults": {"color": {"fixedColor": "orange","mode": "fixed"},"thresholds": {"mode": "absolute","steps": [{"color": "orange","value": null},{"color": "red","value": 1}]}},
"overrides": []
},
"gridPos": {"h": 4,"w": 4,"x": 8,"y": 5},
"id": 12,
"options": {"colorMode": "value","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"textMode": "auto"},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT COUNT(*) AS \"only_rr1\"\nFROM (\n SELECT DISTINCT r1.prefix, r1.prefix_len\n FROM ip_rib r1\n JOIN bgp_peers p1 ON p1.hash_id = r1.peer_hash_id\n JOIN routers rt1 ON rt1.hash_id = p1.router_hash_id\n WHERE rt1.name = '$rr1' AND r1.iswithdrawn = false\n EXCEPT\n SELECT DISTINCT r2.prefix, r2.prefix_len\n FROM ip_rib r2\n JOIN bgp_peers p2 ON p2.hash_id = r2.peer_hash_id\n JOIN routers rt2 ON rt2.hash_id = p2.router_hash_id\n WHERE rt2.name = '$rr2' AND r2.iswithdrawn = false\n) sub",
"refId": "A"
}
],
"title": "Only on $rr1",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Prefixes present on RR2 but missing from RR1",
"fieldConfig": {
"defaults": {"color": {"fixedColor": "orange","mode": "fixed"},"thresholds": {"mode": "absolute","steps": [{"color": "orange","value": null},{"color": "red","value": 1}]}},
"overrides": []
},
"gridPos": {"h": 4,"w": 4,"x": 12,"y": 5},
"id": 13,
"options": {"colorMode": "value","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"textMode": "auto"},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT COUNT(*) AS \"only_rr2\"\nFROM (\n SELECT DISTINCT r2.prefix, r2.prefix_len\n FROM ip_rib r2\n JOIN bgp_peers p2 ON p2.hash_id = r2.peer_hash_id\n JOIN routers rt2 ON rt2.hash_id = p2.router_hash_id\n WHERE rt2.name = '$rr2' AND r2.iswithdrawn = false\n EXCEPT\n SELECT DISTINCT r1.prefix, r1.prefix_len\n FROM ip_rib r1\n JOIN bgp_peers p1 ON p1.hash_id = r1.peer_hash_id\n JOIN routers rt1 ON rt1.hash_id = p1.router_hash_id\n WHERE rt1.name = '$rr1' AND r1.iswithdrawn = false\n) sub",
"refId": "A"
}
],
"title": "Only on $rr2",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Prefixes present on both RRs but with different best-path attributes (next-hop, AS path, MED, or local-pref)",
"fieldConfig": {
"defaults": {"color": {"fixedColor": "yellow","mode": "fixed"},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 1},{"color": "red","value": 10}]}},
"overrides": []
},
"gridPos": {"h": 4,"w": 4,"x": 16,"y": 5},
"id": 14,
"options": {"colorMode": "value","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"textMode": "auto"},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "WITH c1 AS (\n SELECT DISTINCT ON (r.prefix, r.prefix_len)\n r.prefix, r.prefix_len, ba.next_hop, ba.as_path,\n COALESCE(ba.local_pref, 0) AS lp, COALESCE(ba.med, 0) AS med\n FROM ip_rib r\n JOIN bgp_peers p ON p.hash_id = r.peer_hash_id\n JOIN routers rt ON rt.hash_id = p.router_hash_id\n JOIN base_attrs ba ON ba.hash_id = r.base_attr_hash_id\n WHERE rt.name = '$rr1' AND r.iswithdrawn = false\n ORDER BY r.prefix, r.prefix_len, ba.local_pref DESC NULLS LAST\n),\nc2 AS (\n SELECT DISTINCT ON (r.prefix, r.prefix_len)\n r.prefix, r.prefix_len, ba.next_hop, ba.as_path,\n COALESCE(ba.local_pref, 0) AS lp, COALESCE(ba.med, 0) AS med\n FROM ip_rib r\n JOIN bgp_peers p ON p.hash_id = r.peer_hash_id\n JOIN routers rt ON rt.hash_id = p.router_hash_id\n JOIN base_attrs ba ON ba.hash_id = r.base_attr_hash_id\n WHERE rt.name = '$rr2' AND r.iswithdrawn = false\n ORDER BY r.prefix, r.prefix_len, ba.local_pref DESC NULLS LAST\n)\nSELECT COUNT(*) AS \"attr_diffs\"\nFROM c1 JOIN c2 ON c1.prefix = c2.prefix AND c1.prefix_len = c2.prefix_len\nWHERE c1.next_hop != c2.next_hop OR c1.as_path != c2.as_path\n OR c1.lp != c2.lp OR c1.med != c2.med",
"refId": "A"
}
],
"title": "Attribute Diffs",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Total RIB entries (including multiple paths per prefix) on each RR",
"fieldConfig": {
"defaults": {"color": {"fixedColor": "purple","mode": "fixed"},"thresholds": {"mode": "absolute","steps": [{"color": "purple","value": null}]}},
"overrides": []
},
"gridPos": {"h": 4,"w": 4,"x": 20,"y": 5},
"id": 15,
"options": {"colorMode": "value","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"textMode": "auto"},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n SUM(CASE WHEN rt.name = '$rr1' THEN 1 ELSE 0 END) AS \"RR1 Total Paths\",\n SUM(CASE WHEN rt.name = '$rr2' THEN 1 ELSE 0 END) AS \"RR2 Total Paths\"\nFROM ip_rib r\nJOIN bgp_peers p ON p.hash_id = r.peer_hash_id\nJOIN routers rt ON rt.hash_id = p.router_hash_id\nWHERE rt.name IN ('$rr1', '$rr2') AND r.iswithdrawn = false",
"refId": "A"
}
],
"title": "Total RIB Paths",
"type": "stat"
},
{
"collapsed": false,
"gridPos": {"h": 1,"w": 24,"x": 0,"y": 9},
"id": 20,
"title": "Missing Prefixes",
"type": "row"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Prefixes present on RR1 but NOT on RR2. Expected: RR2's own locally-originated routes won't appear here (they're on RR2 only). Unexpected entries indicate a convergence or session issue.",
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"},"custom": {"align": "auto","displayMode": "auto","filterable": true},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null}]}},
"overrides": [
{"matcher": {"id": "byName","options": "Learned From"},"properties": [{"id": "custom.width","value": 130}]},
{"matcher": {"id": "byName","options": "Origin AS"},"properties": [{"id": "custom.width","value": 90}]},
{"matcher": {"id": "byName","options": "Next Hop"},"properties": [{"id": "custom.width","value": 140}]}
]
},
"gridPos": {"h": 10,"w": 12,"x": 0,"y": 10},
"id": 21,
"options": {"footer": {"fields": "","reducer": ["count"],"show": true},"showHeader": true,"sortBy": [{"desc": false,"displayName": "Prefix"}]},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT DISTINCT ON (r1.prefix, r1.prefix_len)\n r1.prefix::text AS \"Prefix\",\n ba.origin_as AS \"Origin AS\",\n ba.next_hop::text AS \"Next Hop\",\n ba.as_path::text AS \"AS Path\",\n p1.peer_addr::text AS \"Learned From\"\nFROM ip_rib r1\nJOIN bgp_peers p1 ON p1.hash_id = r1.peer_hash_id\nJOIN routers rt1 ON rt1.hash_id = p1.router_hash_id\nJOIN base_attrs ba ON ba.hash_id = r1.base_attr_hash_id\nWHERE rt1.name = '$rr1' AND r1.iswithdrawn = false\n AND NOT EXISTS (\n SELECT 1 FROM ip_rib r2\n JOIN bgp_peers p2 ON p2.hash_id = r2.peer_hash_id\n JOIN routers rt2 ON rt2.hash_id = p2.router_hash_id\n WHERE rt2.name = '$rr2'\n AND r2.prefix = r1.prefix AND r2.prefix_len = r1.prefix_len\n AND r2.iswithdrawn = false\n )\nORDER BY r1.prefix, r1.prefix_len, ba.local_pref DESC NULLS LAST",
"refId": "A"
}
],
"title": "Prefixes Only on $rr1",
"type": "table"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Prefixes present on RR2 but NOT on RR1. Expected: RR1's own locally-originated routes won't appear here. Unexpected entries indicate a convergence or session issue.",
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"},"custom": {"align": "auto","displayMode": "auto","filterable": true},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null}]}},
"overrides": [
{"matcher": {"id": "byName","options": "Learned From"},"properties": [{"id": "custom.width","value": 130}]},
{"matcher": {"id": "byName","options": "Origin AS"},"properties": [{"id": "custom.width","value": 90}]},
{"matcher": {"id": "byName","options": "Next Hop"},"properties": [{"id": "custom.width","value": 140}]}
]
},
"gridPos": {"h": 10,"w": 12,"x": 12,"y": 10},
"id": 22,
"options": {"footer": {"fields": "","reducer": ["count"],"show": true},"showHeader": true,"sortBy": [{"desc": false,"displayName": "Prefix"}]},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT DISTINCT ON (r2.prefix, r2.prefix_len)\n r2.prefix::text AS \"Prefix\",\n ba.origin_as AS \"Origin AS\",\n ba.next_hop::text AS \"Next Hop\",\n ba.as_path::text AS \"AS Path\",\n p2.peer_addr::text AS \"Learned From\"\nFROM ip_rib r2\nJOIN bgp_peers p2 ON p2.hash_id = r2.peer_hash_id\nJOIN routers rt2 ON rt2.hash_id = p2.router_hash_id\nJOIN base_attrs ba ON ba.hash_id = r2.base_attr_hash_id\nWHERE rt2.name = '$rr2' AND r2.iswithdrawn = false\n AND NOT EXISTS (\n SELECT 1 FROM ip_rib r1\n JOIN bgp_peers p1 ON p1.hash_id = r1.peer_hash_id\n JOIN routers rt1 ON rt1.hash_id = p1.router_hash_id\n WHERE rt1.name = '$rr1'\n AND r1.prefix = r2.prefix AND r1.prefix_len = r2.prefix_len\n AND r1.iswithdrawn = false\n )\nORDER BY r2.prefix, r2.prefix_len, ba.local_pref DESC NULLS LAST",
"refId": "A"
}
],
"title": "Prefixes Only on $rr2",
"type": "table"
},
{
"collapsed": false,
"gridPos": {"h": 1,"w": 24,"x": 0,"y": 20},
"id": 30,
"title": "Attribute Differences (Same Prefix, Different Best Path)",
"type": "row"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Prefixes present on both RRs but where the selected best path differs in next-hop, AS path, local-pref, or MED. This is normal for multi-homed link subnets where each RR selects a different best path based on router-id tiebreaker. Filter by address family using the AFI dropdown.",
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"},"custom": {"align": "auto","displayMode": "auto","filterable": true},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null}]}},
"overrides": [
{"matcher": {"id": "byName","options": "RR1 Next Hop"},"properties": [{"id": "custom.displayMode","value": "color-text"},{"id": "color","value": {"mode": "fixed","fixedColor": "blue"}}]},
{"matcher": {"id": "byName","options": "RR2 Next Hop"},"properties": [{"id": "custom.displayMode","value": "color-text"},{"id": "color","value": {"mode": "fixed","fixedColor": "green"}}]},
{"matcher": {"id": "byName","options": "Diff Type"},"properties": [{"id": "custom.displayMode","value": "color-background"},{"id": "color","value": {"mode": "thresholds"}},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "yellow","value": null}]}}]}
]
},
"gridPos": {"h": 12,"w": 24,"x": 0,"y": 21},
"id": 31,
"options": {"footer": {"fields": "","reducer": ["count"],"show": true},"showHeader": true,"sortBy": [{"desc": false,"displayName": "Prefix"}]},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "WITH c1 AS (\n SELECT DISTINCT ON (r.prefix, r.prefix_len)\n r.prefix, r.prefix_len, r.isipv4,\n ba.next_hop, ba.as_path, ba.origin_as,\n COALESCE(ba.local_pref, 0) AS lp, COALESCE(ba.med, 0) AS med,\n ba.community_list, p.peer_addr AS learned_from\n FROM ip_rib r\n JOIN bgp_peers p ON p.hash_id = r.peer_hash_id\n JOIN routers rt ON rt.hash_id = p.router_hash_id\n JOIN base_attrs ba ON ba.hash_id = r.base_attr_hash_id\n WHERE rt.name = '$rr1' AND r.iswithdrawn = false\n ORDER BY r.prefix, r.prefix_len, ba.local_pref DESC NULLS LAST\n),\nc2 AS (\n SELECT DISTINCT ON (r.prefix, r.prefix_len)\n r.prefix, r.prefix_len, r.isipv4,\n ba.next_hop, ba.as_path, ba.origin_as,\n COALESCE(ba.local_pref, 0) AS lp, COALESCE(ba.med, 0) AS med,\n ba.community_list, p.peer_addr AS learned_from\n FROM ip_rib r\n JOIN bgp_peers p ON p.hash_id = r.peer_hash_id\n JOIN routers rt ON rt.hash_id = p.router_hash_id\n JOIN base_attrs ba ON ba.hash_id = r.base_attr_hash_id\n WHERE rt.name = '$rr2' AND r.iswithdrawn = false\n ORDER BY r.prefix, r.prefix_len, ba.local_pref DESC NULLS LAST\n)\nSELECT\n c1.prefix::text AS \"Prefix\",\n CASE WHEN c1.isipv4 THEN 'IPv4' ELSE 'IPv6' END AS \"AFI\",\n c1.next_hop::text AS \"RR1 Next Hop\",\n c2.next_hop::text AS \"RR2 Next Hop\",\n c1.as_path::text AS \"RR1 AS Path\",\n c2.as_path::text AS \"RR2 AS Path\",\n c1.lp AS \"RR1 LP\",\n c2.lp AS \"RR2 LP\",\n c1.med AS \"RR1 MED\",\n c2.med AS \"RR2 MED\",\n CASE\n WHEN c1.next_hop != c2.next_hop AND c1.as_path != c2.as_path THEN 'NH+ASPath'\n WHEN c1.next_hop != c2.next_hop THEN 'Next-Hop'\n WHEN c1.as_path != c2.as_path THEN 'AS Path'\n WHEN c1.lp != c2.lp THEN 'Local-Pref'\n WHEN c1.med != c2.med THEN 'MED'\n ELSE 'Other'\n END AS \"Diff Type\"\nFROM c1 JOIN c2 ON c1.prefix = c2.prefix AND c1.prefix_len = c2.prefix_len\nWHERE (c1.next_hop != c2.next_hop OR c1.as_path != c2.as_path\n OR c1.lp != c2.lp OR c1.med != c2.med)\n AND ('$afi' = 'All' OR ('$afi' = 'IPv4' AND c1.isipv4 = true) OR ('$afi' = 'IPv6' AND c1.isipv4 = false))\nORDER BY c1.prefix",
"refId": "A"
}
],
"title": "Attribute Differences — $rr1 vs $rr2",
"type": "table"
},
{
"collapsed": false,
"gridPos": {"h": 1,"w": 24,"x": 0,"y": 33},
"id": 40,
"title": "Per-Client Consistency",
"type": "row"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Route counts each RR client sends to each RR. In a healthy cluster, every client should send the same number of routes to both RRs. Mismatches indicate session issues, policy differences, or BMP reporting gaps.",
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"},"custom": {"align": "auto","displayMode": "auto","filterable": true},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null}]}},
"overrides": [
{"matcher": {"id": "byName","options": "Delta"},"properties": [{"id": "custom.displayMode","value": "color-background"},{"id": "color","value": {"mode": "thresholds"}},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 1},{"color": "red","value": 5}]}}]},
{"matcher": {"id": "byName","options": "Status"},"properties": [{"id": "custom.displayMode","value": "color-background"},{"id": "mappings","value": [{"options": {"Match": {"color": "green","index": 0,"text": "Match"},"MISMATCH": {"color": "red","index": 1,"text": "MISMATCH"}},"type": "value"}]}]}
]
},
"gridPos": {"h": 10,"w": 12,"x": 0,"y": 34},
"id": 41,
"options": {"footer": {"fields": "","reducer": ["sum"],"show": false},"showHeader": true,"sortBy": [{"desc": true,"displayName": "Delta"}]},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "WITH rr1_peers AS (\n SELECT p.peer_addr, p.hash_id\n FROM bgp_peers p\n JOIN routers rt ON rt.hash_id = p.router_hash_id\n WHERE rt.name = '$rr1'\n),\nrr2_peers AS (\n SELECT p.peer_addr, p.hash_id\n FROM bgp_peers p\n JOIN routers rt ON rt.hash_id = p.router_hash_id\n WHERE rt.name = '$rr2'\n),\nrr1_counts AS (\n SELECT pp.peer_addr, COUNT(*) AS cnt\n FROM rr1_peers pp\n JOIN ip_rib r ON r.peer_hash_id = pp.hash_id AND r.iswithdrawn = false\n GROUP BY pp.peer_addr\n),\nrr2_counts AS (\n SELECT pp.peer_addr, COUNT(*) AS cnt\n FROM rr2_peers pp\n JOIN ip_rib r ON r.peer_hash_id = pp.hash_id AND r.iswithdrawn = false\n GROUP BY pp.peer_addr\n)\nSELECT\n COALESCE(c1.peer_addr, c2.peer_addr)::text AS \"Client\",\n COALESCE(c1.cnt, 0) AS \"Routes to RR1\",\n COALESCE(c2.cnt, 0) AS \"Routes to RR2\",\n ABS(COALESCE(c1.cnt, 0) - COALESCE(c2.cnt, 0)) AS \"Delta\",\n CASE WHEN COALESCE(c1.cnt, 0) = COALESCE(c2.cnt, 0) THEN 'Match' ELSE 'MISMATCH' END AS \"Status\"\nFROM rr1_counts c1\nFULL OUTER JOIN rr2_counts c2 ON c1.peer_addr = c2.peer_addr\nWHERE c1.peer_addr IS NOT NULL AND c2.peer_addr IS NOT NULL\nORDER BY ABS(COALESCE(c1.cnt, 0) - COALESCE(c2.cnt, 0)) DESC, COALESCE(c1.peer_addr, c2.peer_addr)",
"refId": "A"
}
],
"title": "Per-Client Route Consistency",
"type": "table"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "For clients with route count mismatches, shows which specific prefixes differ. Select a client from the Client dropdown to drill down.",
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"},"custom": {"align": "auto","displayMode": "auto","filterable": true},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null}]}},
"overrides": [
{"matcher": {"id": "byName","options": "Present On"},"properties": [{"id": "custom.displayMode","value": "color-text"},{"id": "color","value": {"mode": "thresholds"}},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "blue","value": null}]}}]}
]
},
"gridPos": {"h": 10,"w": 12,"x": 12,"y": 34},
"id": 42,
"options": {"footer": {"fields": "","reducer": ["count"],"show": true},"showHeader": true,"sortBy": [{"desc": false,"displayName": "Prefix"}]},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "WITH rr1_client_routes AS (\n SELECT r.prefix, r.prefix_len\n FROM ip_rib r\n JOIN bgp_peers p ON p.hash_id = r.peer_hash_id\n JOIN routers rt ON rt.hash_id = p.router_hash_id\n WHERE rt.name = '$rr1' AND p.peer_addr::text = '$client'\n AND r.iswithdrawn = false\n),\nrr2_client_routes AS (\n SELECT r.prefix, r.prefix_len\n FROM ip_rib r\n JOIN bgp_peers p ON p.hash_id = r.peer_hash_id\n JOIN routers rt ON rt.hash_id = p.router_hash_id\n WHERE rt.name = '$rr2' AND p.peer_addr::text = '$client'\n AND r.iswithdrawn = false\n)\nSELECT r1.prefix::text AS \"Prefix\", 'RR1 only' AS \"Present On\"\nFROM rr1_client_routes r1\nWHERE NOT EXISTS (\n SELECT 1 FROM rr2_client_routes r2\n WHERE r2.prefix = r1.prefix AND r2.prefix_len = r1.prefix_len\n)\nUNION ALL\nSELECT r2.prefix::text AS \"Prefix\", 'RR2 only' AS \"Present On\"\nFROM rr2_client_routes r2\nWHERE NOT EXISTS (\n SELECT 1 FROM rr1_client_routes r1\n WHERE r1.prefix = r2.prefix AND r1.prefix_len = r2.prefix_len\n)\nORDER BY \"Prefix\"",
"refId": "A"
}
],
"title": "Client Prefix Diff — $client",
"type": "table"
},
{
"collapsed": false,
"gridPos": {"h": 1,"w": 24,"x": 0,"y": 44},
"id": 50,
"title": "Full RIB Comparison (All Paths)",
"type": "row"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Side-by-side view of all paths for a specific prefix on both RRs. Select a prefix from the Prefix dropdown to drill down.",
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"},"custom": {"align": "auto","displayMode": "auto","filterable": true},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null}]}},
"overrides": [
{"matcher": {"id": "byName","options": "Router"},"properties": [{"id": "custom.displayMode","value": "color-text"},{"id": "color","value": {"mode": "thresholds"}},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "blue","value": null}]}}]}
]
},
"gridPos": {"h": 10,"w": 24,"x": 0,"y": 45},
"id": 51,
"options": {"footer": {"fields": "","reducer": ["count"],"show": true},"showHeader": true,"sortBy": [{"desc": false,"displayName": "Router"}]},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n rt.name AS \"Router\",\n p.peer_addr::text AS \"Learned From\",\n ba.next_hop::text AS \"Next Hop\",\n ba.as_path::text AS \"AS Path\",\n ba.origin_as AS \"Origin AS\",\n COALESCE(ba.local_pref, 0) AS \"Local Pref\",\n COALESCE(ba.med, 0) AS \"MED\",\n ba.community_list::text AS \"Communities\",\n ba.cluster_list::text AS \"Cluster List\",\n ba.originator_id::text AS \"Originator ID\",\n r.labels AS \"Labels\",\n r.timestamp AS \"Last Update\"\nFROM ip_rib r\nJOIN bgp_peers p ON p.hash_id = r.peer_hash_id\nJOIN routers rt ON rt.hash_id = p.router_hash_id\nJOIN base_attrs ba ON ba.hash_id = r.base_attr_hash_id\nWHERE rt.name IN ('$rr1', '$rr2')\n AND r.iswithdrawn = false\n AND r.prefix::text = '$prefix'\nORDER BY rt.name, p.peer_addr",
"refId": "A"
}
],
"title": "All Paths for $prefix",
"type": "table"
}
],
"refresh": "30s",
"schemaVersion": 38,
"tags": ["openbmp", "rr-diff", "bgp"],
"templating": {
"list": [
{
"current": {},
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"definition": "SELECT name FROM routers WHERE state = 'up' ORDER BY name",
"hide": 0,
"includeAll": false,
"label": "RR1",
"multi": false,
"name": "rr1",
"options": [],
"query": "SELECT name FROM routers WHERE state = 'up' ORDER BY name",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
},
{
"current": {},
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"definition": "SELECT name FROM routers WHERE state = 'up' ORDER BY name",
"hide": 0,
"includeAll": false,
"label": "RR2",
"multi": false,
"name": "rr2",
"options": [],
"query": "SELECT name FROM routers WHERE state = 'up' ORDER BY name",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
},
{
"current": {"selected": true,"text": "All","value": "All"},
"hide": 0,
"includeAll": false,
"label": "AFI",
"multi": false,
"name": "afi",
"options": [
{"selected": true,"text": "All","value": "All"},
{"selected": false,"text": "IPv4","value": "IPv4"},
{"selected": false,"text": "IPv6","value": "IPv6"}
],
"query": "All,IPv4,IPv6",
"skipUrlSync": false,
"type": "custom"
},
{
"current": {},
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"definition": "SELECT DISTINCT p.peer_addr::text FROM bgp_peers p JOIN routers rt ON rt.hash_id = p.router_hash_id WHERE rt.name IN ('$rr1', '$rr2') AND p.state = 'up' ORDER BY 1",
"hide": 0,
"includeAll": false,
"label": "Client",
"multi": false,
"name": "client",
"options": [],
"query": "SELECT DISTINCT p.peer_addr::text FROM bgp_peers p JOIN routers rt ON rt.hash_id = p.router_hash_id WHERE rt.name IN ('$rr1', '$rr2') AND p.state = 'up' ORDER BY 1",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
},
{
"current": {},
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"definition": "SELECT DISTINCT r.prefix::text FROM ip_rib r JOIN bgp_peers p ON p.hash_id = r.peer_hash_id JOIN routers rt ON rt.hash_id = p.router_hash_id WHERE rt.name IN ('$rr1', '$rr2') AND r.iswithdrawn = false ORDER BY 1",
"hide": 0,
"includeAll": false,
"label": "Prefix",
"multi": false,
"name": "prefix",
"options": [],
"query": "SELECT DISTINCT r.prefix::text FROM ip_rib r JOIN bgp_peers p ON p.hash_id = r.peer_hash_id JOIN routers rt ON rt.hash_id = p.router_hash_id WHERE rt.name IN ('$rr1', '$rr2') AND r.iswithdrawn = false ORDER BY 1",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
}
]
},
"time": {"from": "now-6h","to": "now"},
"timepicker": {},
"timezone": "",
"title": "RR Loc-RIB Diff",
"uid": "rr-locrib-diff",
"version": 1
}

View File

@ -74,7 +74,7 @@
"targets": [ "targets": [
{ {
"datasource": {"type": "influxdb","uid": "obmp_influxdb"}, "datasource": {"type": "influxdb","uid": "obmp_influxdb"},
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r._field == \"in-octets\" or r._field == \"out-octets\")\n |> toFloat()\n |> derivative(unit: 1s, nonNegative: true)\n |> map(fn: (r) => ({r with _value: if r._value < 0.0 then 0.0 else r._value}))", "query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r._field == \"in-octets\" or r._field == \"out-octets\")\n |> derivative(unit: 1s, nonNegative: true)\n |> map(fn: (r) => ({r with _value: if r._value < 0.0 then 0.0 else r._value}))",
"refId": "A" "refId": "A"
} }
], ],

View File

@ -59,7 +59,7 @@
"targets": [ "targets": [
{ {
"datasource": {"type": "influxdb","uid": "obmp_influxdb"}, "datasource": {"type": "influxdb","uid": "obmp_influxdb"},
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-errors\" or r._field == \"out-errors\" or r._field == \"in-fcs-errors\")\n |> toFloat()\n |> derivative(unit: 1s, nonNegative: true)", "query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-errors\" or r._field == \"out-errors\" or r._field == \"in-fcs-errors\")\n |> derivative(unit: 1s, nonNegative: true)",
"refId": "A" "refId": "A"
} }
], ],
@ -84,7 +84,7 @@
"targets": [ "targets": [
{ {
"datasource": {"type": "influxdb","uid": "obmp_influxdb"}, "datasource": {"type": "influxdb","uid": "obmp_influxdb"},
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-discards\" or r._field == \"out-discards\")\n |> toFloat()\n |> derivative(unit: 1s, nonNegative: true)", "query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-discards\" or r._field == \"out-discards\")\n |> derivative(unit: 1s, nonNegative: true)",
"refId": "A" "refId": "A"
} }
], ],
@ -114,7 +114,7 @@
"targets": [ "targets": [
{ {
"datasource": {"type": "influxdb","uid": "obmp_influxdb"}, "datasource": {"type": "influxdb","uid": "obmp_influxdb"},
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-errors\" or r._field == \"out-errors\" or r._field == \"in-fcs-errors\" or r._field == \"in-discards\" or r._field == \"out-discards\")\n |> toFloat()\n |> last()\n |> pivot(rowKey: [\"_time\"], columnKey: [\"_field\"], valueColumn: \"_value\")\n |> keep(columns: [\"source\", \"name\", \"in-errors\", \"out-errors\", \"in-fcs-errors\", \"in-discards\", \"out-discards\"])\n |> sort(columns: [\"in-errors\"], desc: true)", "query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-errors\" or r._field == \"out-errors\" or r._field == \"in-fcs-errors\" or r._field == \"in-discards\" or r._field == \"out-discards\")\n |> last()\n |> pivot(rowKey: [\"_time\"], columnKey: [\"_field\"], valueColumn: \"_value\")\n |> keep(columns: [\"source\", \"name\", \"in-errors\", \"out-errors\", \"in-fcs-errors\", \"in-discards\", \"out-discards\"])\n |> sort(columns: [\"in-errors\"], desc: true)",
"refId": "A" "refId": "A"
} }
], ],

View File

@ -59,7 +59,7 @@
"targets": [ "targets": [
{ {
"datasource": {"type": "influxdb","uid": "obmp_influxdb"}, "datasource": {"type": "influxdb","uid": "obmp_influxdb"},
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-octets\" or r._field == \"out-octets\")\n |> toFloat()\n |> derivative(unit: 1s, nonNegative: true)\n |> map(fn: (r) => ({r with _value: if r._value < 0.0 then 0.0 else r._value}))", "query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-octets\" or r._field == \"out-octets\")\n |> derivative(unit: 1s, nonNegative: true)\n |> map(fn: (r) => ({r with _value: if r._value < 0.0 then 0.0 else r._value}))",
"refId": "A" "refId": "A"
} }
], ],
@ -84,7 +84,7 @@
"targets": [ "targets": [
{ {
"datasource": {"type": "influxdb","uid": "obmp_influxdb"}, "datasource": {"type": "influxdb","uid": "obmp_influxdb"},
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-pkts\" or r._field == \"out-pkts\")\n |> toFloat()\n |> derivative(unit: 1s, nonNegative: true)\n |> map(fn: (r) => ({r with _value: if r._value < 0.0 then 0.0 else r._value}))", "query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-pkts\" or r._field == \"out-pkts\")\n |> derivative(unit: 1s, nonNegative: true)\n |> map(fn: (r) => ({r with _value: if r._value < 0.0 then 0.0 else r._value}))",
"refId": "A" "refId": "A"
} }
], ],
@ -109,7 +109,7 @@
"targets": [ "targets": [
{ {
"datasource": {"type": "influxdb","uid": "obmp_influxdb"}, "datasource": {"type": "influxdb","uid": "obmp_influxdb"},
"query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-octets\" or r._field == \"out-octets\")\n |> toFloat()\n |> derivative(unit: 1s, nonNegative: true)\n |> group(columns: [\"source\", \"name\", \"_field\"])\n |> sum()\n |> group(columns: [\"source\", \"name\"])\n |> sum()\n |> group()\n |> sort(columns: [\"_value\"], desc: true)\n |> limit(n: 20)", "query": "from(bucket: \"telemetry\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"interface_counters\")\n |> filter(fn: (r) => r.source =~ /${router:regex}/)\n |> filter(fn: (r) => r.name =~ /${interface:regex}/)\n |> filter(fn: (r) => r._field == \"in-octets\" or r._field == \"out-octets\")\n |> derivative(unit: 1s, nonNegative: true)\n |> group(columns: [\"source\", \"name\", \"_field\"])\n |> sum()\n |> group(columns: [\"source\", \"name\"])\n |> sum()\n |> group()\n |> sort(columns: [\"_value\"], desc: true)\n |> limit(n: 20)",
"refId": "A" "refId": "A"
} }
], ],

View File

@ -5,7 +5,7 @@ datasources:
uid: obmp_influxdb uid: obmp_influxdb
type: influxdb type: influxdb
access: proxy access: proxy
url: http://10.40.40.202:8086 url: http://obmp-influxdb:8086
jsonData: jsonData:
version: Flux version: Flux
organization: openbmp organization: openbmp

View File

@ -1,106 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenBMP Lab Portal</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #111217;
color: #d8dee9;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
}
.header {
text-align: center;
margin-bottom: 2.5rem;
}
.header h1 {
font-size: 1.8rem;
color: #e2e8f0;
margin-bottom: 0.5rem;
}
.header p {
color: #7b8da0;
font-size: 0.95rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1.25rem;
max-width: 900px;
width: 100%;
}
.card {
background: #1a1d26;
border: 1px solid #2a2e3a;
border-radius: 10px;
padding: 1.5rem;
text-decoration: none;
color: inherit;
transition: border-color 0.2s, transform 0.15s;
}
.card:hover {
border-color: #3b82f6;
transform: translateY(-2px);
}
.card .icon {
font-size: 2rem;
margin-bottom: 0.75rem;
display: block;
}
.card h2 {
font-size: 1.1rem;
color: #e2e8f0;
margin-bottom: 0.4rem;
}
.card p {
font-size: 0.85rem;
color: #7b8da0;
line-height: 1.4;
}
.footer {
margin-top: 3rem;
color: #4a5568;
font-size: 0.8rem;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<h1>OpenBMP Lab</h1>
<p>BGP Monitoring Protocol &middot; Route Analysis &middot; Telemetry</p>
</div>
<div class="grid">
<a href="/grafana/" class="card">
<span class="icon">&#x1F4CA;</span>
<h2>Grafana Dashboards</h2>
<p>BGP analytics, RR Loc-RIB diff, IS-IS topology, telemetry, and 27+ dashboards.</p>
</a>
<a href="/exabgp/" class="card">
<span class="icon">&#x1F6E4;</span>
<h2>ExaBGP Route Injector</h2>
<p>Inject and withdraw BGP routes into the lab fabric via ExaBGP API.</p>
</a>
<a href="/traffic/" class="card">
<span class="icon">&#x1F680;</span>
<h2>Traffic Generator</h2>
<p>RFC 2544 throughput, latency, and loss testing across the network.</p>
</a>
</div>
<div class="footer">
OpenBMP Docker Stack &middot; 9 IOS-XR Routers &middot; CML Lab
</div>
</body>
</html>

View File

@ -11,11 +11,5 @@ server {
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
} }
} }

View File

@ -6,7 +6,7 @@
<span class="logo-icon">&#9889;</span> <span class="logo-icon">&#9889;</span>
<h1>Traffic Generator</h1> <h1>Traffic Generator</h1>
</div> </div>
<StatusBar :health="health" :api-error="apiError" @modeChanged="fetchHealth(); fetchAll()" /> <StatusBar :health="health" :api-error="apiError" />
</header> </header>
<!-- ERROR BANNER --> <!-- ERROR BANNER -->
@ -19,8 +19,7 @@
<div class="main-content"> <div class="main-content">
<!-- LEFT COLUMN: Flow Builder --> <!-- LEFT COLUMN: Flow Builder -->
<aside class="left-col"> <aside class="left-col">
<FlowBuilder :key="editFlow ? editFlow.id : 'new'" :editFlow="editFlow" <FlowBuilder @created="fetchFlows" @updated="fetchFlows" />
@created="onFlowSaved" @updated="onFlowSaved" @cancel="editFlow = null" />
</aside> </aside>
<!-- RIGHT COLUMN: Tabs --> <!-- RIGHT COLUMN: Tabs -->
@ -38,16 +37,9 @@
</div> </div>
<div class="tab-content"> <div class="tab-content">
<div v-if="activeTab === 'flows'"> <FlowTable v-if="activeTab === 'flows'" :flows="flows" @refresh="fetchFlows" />
<QuickPing /> <TestBuilder v-else-if="activeTab === 'tests'" :flows="flows" @created="fetchTests" @refresh="fetchAll" />
<FlowTable :flows="flows" @refresh="fetchFlows" @edit="startEdit" /> <TestRunner v-else-if="activeTab === 'runner'" :tests="tests" @refresh="fetchTests" />
</div>
<div v-else-if="activeTab === 'tests'">
<TestBuilder @created="fetchTests" @refresh="fetchAll" />
<div style="margin-top: 20px;">
<TestRunner :tests="tests" @refresh="fetchTests" />
</div>
</div>
<ResultsPanel v-else-if="activeTab === 'results'" :tests="tests" /> <ResultsPanel v-else-if="activeTab === 'results'" :tests="tests" />
<StatsMonitor v-else-if="activeTab === 'monitor'" :flows="flows" /> <StatsMonitor v-else-if="activeTab === 'monitor'" :flows="flows" />
</div> </div>
@ -58,20 +50,19 @@
<footer class="app-footer"> <footer class="app-footer">
<span>Refreshing every 5s (health) / 3s (flows)</span> <span>Refreshing every 5s (health) / 3s (flows)</span>
<span class="footer-sep">|</span> <span class="footer-sep">|</span>
<a :href="baseUrl + ':3000'" target="_blank" class="footer-link">Grafana: :3000</a> <a href="http://localhost:3000" target="_blank" class="footer-link">Grafana: :3000</a>
<span class="footer-sep">|</span> <span class="footer-sep">|</span>
<a :href="baseUrl + ':5001'" target="_blank" class="footer-link">Route Injector: :5001</a> <a href="http://localhost:5001" target="_blank" class="footer-link">Route Injector: :5001</a>
</footer> </footer>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { api } from './api.js' import { api } from './api.js'
import StatusBar from './components/StatusBar.vue' import StatusBar from './components/StatusBar.vue'
import FlowBuilder from './components/FlowBuilder.vue' import FlowBuilder from './components/FlowBuilder.vue'
import FlowTable from './components/FlowTable.vue' import FlowTable from './components/FlowTable.vue'
import QuickPing from './components/QuickPing.vue'
import TestBuilder from './components/TestBuilder.vue' import TestBuilder from './components/TestBuilder.vue'
import TestRunner from './components/TestRunner.vue' import TestRunner from './components/TestRunner.vue'
import ResultsPanel from './components/ResultsPanel.vue' import ResultsPanel from './components/ResultsPanel.vue'
@ -82,15 +73,11 @@ const flows = ref([])
const tests = ref([]) const tests = ref([])
const apiError = ref(null) const apiError = ref(null)
const activeTab = ref('flows') const activeTab = ref('flows')
const editFlow = ref(null)
const baseUrl = computed(() => `${window.location.protocol}//${window.location.hostname}`)
function startEdit(flow) { editFlow.value = { ...flow } }
function onFlowSaved() { editFlow.value = null; fetchFlows() }
const tabs = [ const tabs = [
{ id: 'flows', label: 'Flows' }, { id: 'flows', label: 'Flows' },
{ id: 'tests', label: 'Tests' }, { id: 'tests', label: 'Tests' },
{ id: 'runner', label: 'Runner' },
{ id: 'results', label: 'Results' }, { id: 'results', label: 'Results' },
{ id: 'monitor', label: 'Monitor' }, { id: 'monitor', label: 'Monitor' },
] ]

View File

@ -1,4 +1,4 @@
const BASE = '/traffic/api' const BASE = '/api'
async function req(method, path, body) { async function req(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } } const opts = { method, headers: { 'Content-Type': 'application/json' } }
@ -12,7 +12,6 @@ export const api = {
health: () => req('GET', '/healthz'), health: () => req('GET', '/healthz'),
interfaces: () => req('GET', '/interfaces'), interfaces: () => req('GET', '/interfaces'),
mode: () => req('GET', '/mode'), mode: () => req('GET', '/mode'),
setMode: (mode) => req('POST', '/mode', { mode }),
// Flows // Flows
flows: () => req('GET', '/flows'), flows: () => req('GET', '/flows'),
@ -39,9 +38,6 @@ export const api = {
// Stats // Stats
statsHistory: () => req('GET', '/stats/history'), statsHistory: () => req('GET', '/stats/history'),
// Ping
ping: (target, count) => req('POST', '/ping', { target, count: count || 5 }),
// Responder // Responder
responderStats: () => req('GET', '/responder/stats'), responderStats: () => req('GET', '/responder/stats'),
responderReset: () => req('POST', '/responder/reset'), responderReset: () => req('POST', '/responder/reset'),

View File

@ -42,23 +42,14 @@
<input v-model.number="form.frame_size" type="number" min="64" max="9000" /> <input v-model.number="form.frame_size" type="number" min="64" max="9000" />
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Rate</label> <label>Rate (pps)</label>
<input v-model.number="form.rate_val" type="number" min="1" step="any" /> <input v-model.number="form.rate_pps" type="number" min="1" max="100000" />
<select v-model="form.rate_unit" class="rate-unit-standalone">
<option value="pps">pps</option>
<option value="kbps">Kbps</option>
<option value="mbps">Mbps</option>
</select>
</div> </div>
</div> </div>
<div class="form-row-pair"> <div class="form-row-pair">
<div class="form-row"> <div class="form-row">
<label>Duration (sec)</label> <label>Duration (sec)</label>
<input v-model.number="form.duration" type="number" min="0" :disabled="form.continuous" /> <input v-model.number="form.duration" type="number" min="0" />
<label class="checkbox-inline">
<input type="checkbox" v-model="form.continuous" @change="onContinuousChange" />
Continuous
</label>
</div> </div>
<div class="form-row"> <div class="form-row">
<label>DSCP</label> <label>DSCP</label>
@ -80,53 +71,32 @@
</template> </template>
<script setup> <script setup>
import { reactive, computed } from 'vue' import { reactive, watch } from 'vue'
import { api } from '../api.js' import { api } from '../api.js'
const props = defineProps({ editFlow: Object }) const props = defineProps({ editFlow: Object })
const emit = defineEmits(['created', 'updated', 'cancel']) const emit = defineEmits(['created', 'updated', 'cancel'])
const editing = computed(() => !!props.editFlow) const editing = !!props.editFlow
const defaults = { const defaults = {
name: '', dst_ip: '', src_ip: '', dst_mac: '', name: '', dst_ip: '', src_ip: '', dst_mac: '',
protocol: 'udp', src_port: 50000, dst_port: 5001, protocol: 'udp', src_port: 50000, dst_port: 5001,
frame_size: 512, rate_val: 1000, rate_unit: 'pps', duration: 30, frame_size: 512, rate_pps: 1000, duration: 30,
dscp: 0, responder_url: '', continuous: false, dscp: 0, responder_url: '',
} }
function ppsToDisplay(pps, frameSize) { const form = reactive({ ...defaults, ...(props.editFlow || {}) })
// Convert stored PPS to a friendlier unit if it was originally set that way
return { rate_val: pps, rate_unit: 'pps' }
}
const initData = props.editFlow
? { ...props.editFlow, continuous: props.editFlow.duration === 0, ...ppsToDisplay(props.editFlow.rate_pps, props.editFlow.frame_size) }
: {}
const form = reactive({ ...defaults, ...initData })
function onContinuousChange() { if (form.continuous) form.duration = 0 }
function computePps(val, unit, frameSize) {
if (unit === 'kbps') return Math.max(1, Math.round((val * 1000) / (frameSize * 8)))
if (unit === 'mbps') return Math.max(1, Math.round((val * 1_000_000) / (frameSize * 8)))
return Math.round(val)
}
async function submit() { async function submit() {
try { try {
const payload = { ...form } const payload = { ...form }
payload.rate_pps = computePps(form.rate_val, form.rate_unit, form.frame_size)
delete payload.rate_val
delete payload.rate_unit
delete payload.continuous
if (form.continuous) payload.duration = 0
if (!payload.src_ip) delete payload.src_ip if (!payload.src_ip) delete payload.src_ip
if (!payload.dst_mac) delete payload.dst_mac if (!payload.dst_mac) delete payload.dst_mac
if (!payload.responder_url) delete payload.responder_url if (!payload.responder_url) delete payload.responder_url
if (!payload.name) payload.name = `${payload.protocol.toUpperCase()} -> ${payload.dst_ip}` if (!payload.name) payload.name = `${payload.protocol.toUpperCase()} -> ${payload.dst_ip}`
if (editing.value) { if (editing) {
await api.updateFlow(props.editFlow.id, payload) await api.updateFlow(props.editFlow.id, payload)
emit('updated') emit('updated')
} else { } else {
@ -153,7 +123,4 @@ h3 { font-size: 15px; margin-bottom: 12px; color: var(--accent); }
.btn-accent:hover { opacity: 0.9; } .btn-accent:hover { opacity: 0.9; }
.btn-accent:disabled { opacity: 0.4; } .btn-accent:disabled { opacity: 0.4; }
.btn-muted { background: var(--border); color: var(--text); } .btn-muted { background: var(--border); color: var(--text); }
.rate-unit-standalone { width: 100%; margin-top: 4px; }
.checkbox-inline { display: inline-flex !important; align-items: center; gap: 4px; margin-top: 4px; font-size: 12px; cursor: pointer; }
.checkbox-inline input { width: auto; }
</style> </style>

View File

@ -10,9 +10,7 @@
<th>Size</th> <th>Size</th>
<th>Rate</th> <th>Rate</th>
<th>State</th> <th>State</th>
<th>TX Pkts</th>
<th>TX pps</th> <th>TX pps</th>
<th>RX Pkts</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@ -22,17 +20,14 @@
<td class="mono">{{ f.dst_ip }}</td> <td class="mono">{{ f.dst_ip }}</td>
<td>{{ f.protocol.toUpperCase() }}</td> <td>{{ f.protocol.toUpperCase() }}</td>
<td>{{ f.frame_size }}B</td> <td>{{ f.frame_size }}B</td>
<td>{{ formatRate(f) }}</td> <td>{{ f.rate_pps }} pps</td>
<td> <td>
<span class="state-badge" :class="'state-' + f.state">{{ f.state }}</span> <span class="state-badge" :class="'state-' + f.state">{{ f.state }}</span>
</td> </td>
<td class="mono">{{ formatNum(f.stats?.tx_packets || 0) }}</td> <td class="mono">{{ stats[f.id]?.tx_pps || 0 }}</td>
<td class="mono">{{ pps[f.id] || 0 }}</td>
<td class="mono">{{ formatNum(f.stats?.rx_packets || 0) }}</td>
<td class="actions"> <td class="actions">
<button v-if="f.state !== 'running'" class="btn-sm btn-go" @click="start(f.id)">Start</button> <button v-if="f.state !== 'running'" class="btn-sm btn-go" @click="start(f.id)">Start</button>
<button v-else class="btn-sm btn-stop" @click="stop(f.id)">Stop</button> <button v-else class="btn-sm btn-stop" @click="stop(f.id)">Stop</button>
<button class="btn-sm btn-edit" @click="emit('edit', f)" :disabled="f.state === 'running'">Edit</button>
<button class="btn-sm btn-del" @click="del(f.id)" :disabled="f.state === 'running'">Del</button> <button class="btn-sm btn-del" @click="del(f.id)" :disabled="f.state === 'running'">Del</button>
</td> </td>
</tr> </tr>
@ -46,40 +41,23 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { api } from '../api.js' import { api } from '../api.js'
const props = defineProps({ flows: Array }) const props = defineProps({ flows: Array })
const emit = defineEmits(['refresh', 'edit']) const emit = defineEmits(['refresh'])
const pps = ref({}) const stats = ref({})
const prevTx = ref({})
let statsTimer = null let statsTimer = null
function computePps() { async function fetchStats() {
for (const f of (props.flows || [])) { for (const f of (props.flows || [])) {
const txNow = f.stats?.tx_packets || 0 if (f.state === 'running') {
const prev = prevTx.value[f.id] || 0 try {
if (f.state === 'running' && prev > 0) { const s = await api.flowStats(f.id)
pps.value[f.id] = Math.max(0, txNow - prev) stats.value[f.id] = s
} else if (f.state !== 'running') { } catch (_) {}
pps.value[f.id] = 0
} }
prevTx.value[f.id] = txNow
} }
} }
function formatRate(f) { onMounted(() => { statsTimer = setInterval(fetchStats, 1000) })
const pps = f.rate_pps || 0
const mbps = (pps * (f.frame_size || 64) * 8) / 1_000_000
if (mbps >= 1) return mbps.toFixed(1) + ' Mbps'
if (mbps >= 0.001) return (mbps * 1000).toFixed(0) + ' Kbps'
return pps + ' pps'
}
function formatNum(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
return n
}
onMounted(() => { statsTimer = setInterval(computePps, 1000) })
onUnmounted(() => { clearInterval(statsTimer) }) onUnmounted(() => { clearInterval(statsTimer) })
async function start(id) { async function start(id) {
@ -110,7 +88,5 @@ tr.running { background: rgba(79,156,249,0.05); }
.btn-go { background: var(--success); color: #fff; } .btn-go { background: var(--success); color: #fff; }
.btn-stop { background: var(--warning); color: #000; } .btn-stop { background: var(--warning); color: #000; }
.btn-del { background: rgba(252,129,129,0.15); color: var(--danger); } .btn-del { background: rgba(252,129,129,0.15); color: var(--danger); }
.btn-edit { background: rgba(79,156,249,0.15); color: var(--accent); }
.btn-edit:disabled { opacity: 0.3; }
.btn-del:disabled { opacity: 0.3; } .btn-del:disabled { opacity: 0.3; }
</style> </style>

View File

@ -1,76 +0,0 @@
<template>
<div class="quick-ping">
<div class="ping-row">
<input
v-model="target"
placeholder="IP address to ping..."
@keyup.enter="runPing"
:disabled="pinging"
/>
<button class="btn-ping" @click="runPing" :disabled="!target || pinging">
{{ pinging ? 'Pinging...' : 'Ping' }}
</button>
</div>
<div v-if="result" class="ping-result" :class="result.reachable ? 'reachable' : 'unreachable'">
<div class="ping-summary">
<span class="ping-target">{{ result.target }}</span>
<span v-if="result.reachable" class="ping-status ok">Reachable</span>
<span v-else class="ping-status fail">Unreachable</span>
</div>
<div v-if="result.reachable && result.stats" class="ping-stats">
<span>{{ result.received }}/{{ result.sent }} replies</span>
<span>Min: {{ result.stats.min_ms }}ms</span>
<span>Avg: {{ result.stats.avg_ms }}ms</span>
<span>Max: {{ result.stats.max_ms }}ms</span>
<span>Loss: {{ result.loss_pct }}%</span>
</div>
<div v-if="result.error" class="ping-error">{{ result.error }}</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { api } from '../api.js'
const target = ref('')
const pinging = ref(false)
const result = ref(null)
async function runPing() {
if (!target.value || pinging.value) return
pinging.value = true
result.value = null
try {
result.value = await api.ping(target.value, 5)
} catch (e) {
result.value = { target: target.value, reachable: false, error: e.message }
} finally {
pinging.value = false
}
}
</script>
<style scoped>
.quick-ping { margin-bottom: 16px; }
.ping-row { display: flex; gap: 6px; }
.ping-row input { flex: 1; }
.btn-ping {
padding: 6px 16px; font-weight: 600; font-size: 13px;
background: var(--accent); color: #fff; white-space: nowrap;
}
.btn-ping:disabled { opacity: 0.4; }
.ping-result {
margin-top: 8px; padding: 8px 12px;
border-radius: var(--radius); font-size: 13px;
}
.ping-result.reachable { background: rgba(72,187,120,0.1); border: 1px solid rgba(72,187,120,0.3); }
.ping-result.unreachable { background: rgba(252,129,129,0.1); border: 1px solid rgba(252,129,129,0.3); }
.ping-summary { display: flex; align-items: center; gap: 10px; }
.ping-target { font-weight: 600; font-family: monospace; }
.ping-status { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 600; }
.ping-status.ok { background: rgba(72,187,120,0.2); color: var(--success); }
.ping-status.fail { background: rgba(252,129,129,0.2); color: var(--danger); }
.ping-stats { display: flex; gap: 12px; margin-top: 6px; font-size: 12px; color: var(--muted); font-family: monospace; }
.ping-error { margin-top: 4px; color: var(--danger); font-size: 12px; }
</style>

View File

@ -13,30 +13,8 @@
</div> </div>
</div> </div>
<div v-if="t.error" class="error-msg">Error: {{ t.error }}</div> <div v-if="t.results" class="result-table">
<div v-if="t.results && Object.keys(t.results).length" class="result-table"> <table>
<!-- Frame Loss: array of rate steps per frame size -->
<template v-if="t.type === 'frame_loss'">
<div v-for="(rates, size) in t.results" :key="size" class="fl-section">
<div class="fl-title">Frame Size: {{ size }} B</div>
<table>
<thead>
<tr><th>Rate %</th><th>Rate (pps)</th><th>TX Packets</th><th>RX Packets</th><th>Loss %</th></tr>
</thead>
<tbody>
<tr v-for="r in rates" :key="r.rate_pct">
<td class="mono">{{ r.rate_pct }}%</td>
<td class="mono">{{ r.rate_pps }}</td>
<td class="mono">{{ r.tx_packets }}</td>
<td class="mono">{{ r.rx_packets }}</td>
<td class="mono">{{ r.loss_pct }}%</td>
</tr>
</tbody>
</table>
</div>
</template>
<!-- Other test types -->
<table v-else>
<thead> <thead>
<tr> <tr>
<th>Frame Size (B)</th> <th>Frame Size (B)</th>
@ -54,7 +32,7 @@
</table> </table>
</div> </div>
<div v-if="t.results && t.type !== 'frame_loss'" class="result-chart"> <div v-if="t.results" class="result-chart">
<div class="bar-chart"> <div class="bar-chart">
<div v-for="(val, size) in t.results" :key="size" class="bar-item"> <div v-for="(val, size) in t.results" :key="size" class="bar-item">
<div class="bar-fill" :style="{ height: barHeight(t, val) + '%' }"></div> <div class="bar-fill" :style="{ height: barHeight(t, val) + '%' }"></div>
@ -72,7 +50,7 @@ import { computed } from 'vue'
const props = defineProps({ tests: Array }) const props = defineProps({ tests: Array })
const completedTests = computed(() => const completedTests = computed(() =>
(props.tests || []).filter(t => (t.state === 'complete' || t.state === 'error') && (t.results || t.error)) (props.tests || []).filter(t => t.state === 'complete' && t.results)
) )
function resultColumns(t) { function resultColumns(t) {
@ -85,28 +63,22 @@ function resultColumns(t) {
function formatVal(val, col) { function formatVal(val, col) {
if (typeof val === 'object') { if (typeof val === 'object') {
if (col.includes('Rate')) return val.max_throughput_pps ?? val.max_rate_pps ?? '-' if (col.includes('Rate')) return val.max_rate_pps ?? '-'
if (col.includes('Throughput')) { if (col.includes('Throughput')) return val.throughput_mbps ?? '-'
const pps = val.max_throughput_pps ?? val.max_rate_pps ?? 0 if (col.includes('Min')) return val.min_ms ?? '-'
const fs = val.frame_size ?? 64 if (col.includes('Avg')) return val.avg_ms ?? '-'
return pps ? ((pps * fs * 8) / 1_000_000).toFixed(2) : '-' if (col.includes('Max') && col.includes('ms')) return val.max_ms ?? '-'
} if (col.includes('Jitter')) return val.jitter_ms ?? '-'
if (col.includes('Min')) return val.min_ms != null ? val.min_ms.toFixed(2) : '-'
if (col.includes('Avg')) return val.avg_ms != null ? val.avg_ms.toFixed(2) : '-'
if (col.includes('Max') && col.includes('ms')) return val.max_ms != null ? val.max_ms.toFixed(2) : '-'
if (col.includes('Jitter')) return val.jitter_ms != null ? val.jitter_ms.toFixed(2) : '-'
if (col.includes('Loss')) return val.loss_pct ?? '-' if (col.includes('Loss')) return val.loss_pct ?? '-'
if (col.includes('Burst')) return val.max_burst_frames ?? val.max_burst ?? '-' if (col.includes('Burst')) return val.max_burst ?? '-'
return JSON.stringify(val) return JSON.stringify(val)
} }
return val return val
} }
function barHeight(t, val) { function barHeight(t, val) {
const v = typeof val === 'object' ? (val.max_throughput_pps || val.max_rate_pps || val.avg_ms || val.loss_pct || val.max_burst_frames || 0) : val const v = typeof val === 'object' ? (val.max_rate_pps || val.avg_ms || val.loss_pct || val.max_burst || 0) : val
const allVals = Object.values(t.results).map(r => typeof r === 'object' ? (r.max_throughput_pps || r.max_rate_pps || r.avg_ms || r.loss_pct || r.max_burst_frames || 0) : r) return Math.min(100, Math.max(5, v / 100))
const maxVal = Math.max(...allVals, 1)
return Math.min(100, Math.max(5, (v / maxVal) * 100))
} }
function exportJSON(t) { function exportJSON(t) {
@ -151,7 +123,4 @@ td { font-size: 13px; padding: 4px 8px; }
.bar-item { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; } .bar-item { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; }
.bar-fill { width: 100%; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 4px; transition: height 0.3s; margin-top: auto; } .bar-fill { width: 100%; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 4px; transition: height 0.3s; margin-top: auto; }
.bar-label { font-size: 10px; color: var(--muted); margin-top: 4px; } .bar-label { font-size: 10px; color: var(--muted); margin-top: 4px; }
.fl-section { margin-bottom: 12px; }
.fl-title { font-size: 12px; font-weight: 600; color: var(--accent); margin-bottom: 4px; }
.error-msg { color: var(--danger); font-size: 13px; padding: 8px 0; }
</style> </style>

View File

@ -78,79 +78,12 @@ let timer = null
async function fetchStats() { async function fetchStats() {
try { try {
if (selectedFlow.value) { if (selectedFlow.value) {
// Single flow: /flows/<id>/stats returns {flow_id, counters, rates}
// rates contains: tx_pps, rx_pps, tx_mbps, rx_mbps, loss_pct, tx_packets, tx_bytes, etc.
const s = await api.flowStats(selectedFlow.value) const s = await api.flowStats(selectedFlow.value)
const rates = s.rates || {} current.value = s
const counters = s.counters || {}
current.value = {
tx_pps: Math.round(rates.tx_pps || 0),
rx_pps: Math.round(rates.rx_pps || 0),
tx_mbps: rates.tx_mbps || 0,
rx_mbps: rates.rx_mbps || 0,
loss_pct: rates.loss_pct || 0,
avg_latency_ms: rates.latency ? rates.latency.avg_ms : null,
tx_packets: counters.tx_packets || 0,
tx_bytes: counters.tx_bytes || 0,
rx_packets: counters.rx_packets || 0,
rx_bytes: counters.rx_bytes || 0,
}
// Append to history for sparkline
history.value.push({ tx_pps: current.value.tx_pps, rx_pps: current.value.rx_pps })
if (history.value.length > 60) history.value = history.value.slice(-60)
} else { } else {
// All flows: /stats/history returns {history: {flow_id: [samples]}}
const h = await api.statsHistory() const h = await api.statsHistory()
const allHistory = h.history || {} if (h.current) current.value = h.current
// Aggregate latest sample across all flows if (h.history) history.value = h.history.slice(-60)
let txPps = 0, rxPps = 0, txMbps = 0, rxMbps = 0
let txPkts = 0, txBytes = 0, rxPkts = 0, rxBytes = 0
let lossPcts = [], latencies = []
for (const [, samples] of Object.entries(allHistory)) {
if (!samples.length) continue
const latest = samples[samples.length - 1]
txPps += latest.tx_pps || 0
rxPps += latest.rx_pps || 0
txMbps += latest.tx_mbps || 0
rxMbps += latest.rx_mbps || 0
txPkts += latest.tx_packets || 0
txBytes += latest.tx_bytes || 0
rxPkts += latest.rx_packets || 0
rxBytes += latest.rx_bytes || 0
if (latest.loss_pct > 0) lossPcts.push(latest.loss_pct)
if (latest.latency && latest.latency.avg_ms) latencies.push(latest.latency.avg_ms)
}
current.value = {
tx_pps: Math.round(txPps),
rx_pps: Math.round(rxPps),
tx_mbps: txMbps,
rx_mbps: rxMbps,
loss_pct: txPkts > 0 ? Math.max(0, ((txPkts - rxPkts) / txPkts) * 100) : 0,
avg_latency_ms: latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : null,
tx_packets: txPkts,
tx_bytes: txBytes,
rx_packets: rxPkts,
rx_bytes: rxBytes,
}
// Build aggregated sparkline from history samples
// Find max sample count across all flows
const flowIds = Object.keys(allHistory)
if (flowIds.length) {
const maxLen = Math.max(...flowIds.map(id => allHistory[id].length))
const sparkData = []
for (let i = Math.max(0, maxLen - 60); i < maxLen; i++) {
let sTx = 0, sRx = 0
for (const fid of flowIds) {
const s = allHistory[fid][i]
if (s) { sTx += s.tx_pps || 0; sRx += s.rx_pps || 0 }
}
sparkData.push({ tx_pps: Math.round(sTx), rx_pps: Math.round(sRx) })
}
history.value = sparkData
}
} }
} catch (_) {} } catch (_) {}
} }

View File

@ -2,45 +2,25 @@
<div class="status-bar"> <div class="status-bar">
<div class="status-badges"> <div class="status-badges">
<span class="badge" :class="connected ? 'badge-ok' : 'badge-err'"> <span class="badge" :class="connected ? 'badge-ok' : 'badge-err'">
{{ connected ? 'API Connected' : 'API Offline' }} {{ connected ? 'Connected' : 'Offline' }}
</span>
<span v-if="health" class="badge badge-mode" :class="'mode-' + (health.mode || 'sender')" @click="toggleMode">
{{ (health.mode || 'sender').toUpperCase() }}
</span> </span>
<span v-if="health" class="badge badge-info"> <span v-if="health" class="badge badge-info">
Active Flows: {{ health.active_flows || 0 }} Mode: {{ health.mode || 'sender' }}
</span> </span>
<span v-if="health" class="badge badge-info"> <span v-if="health" class="badge badge-info">
Active Tests: {{ health.active_tests || 0 }} Flows: {{ health.active_flows || 0 }}
</span>
<span v-if="health" class="badge badge-info">
Tests: {{ health.active_tests || 0 }}
</span> </span>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed } from 'vue'
import { api } from '../api.js'
const props = defineProps({ health: Object, apiError: String }) const props = defineProps({ health: Object, apiError: String })
const emit = defineEmits(['modeChanged'])
const connected = computed(() => !props.apiError && props.health) const connected = computed(() => !props.apiError && props.health)
const switching = ref(false)
async function toggleMode() {
if (switching.value || !props.health) return
const current = props.health.mode || 'sender'
const next = current === 'sender' ? 'responder' : 'sender'
if (!confirm(`Switch to ${next.toUpperCase()} mode? This will stop all active flows/tests.`)) return
switching.value = true
try {
await api.setMode(next)
emit('modeChanged')
} catch (e) {
alert('Failed to switch mode: ' + e.message)
} finally {
switching.value = false
}
}
</script> </script>
<style scoped> <style scoped>
@ -53,9 +33,5 @@ async function toggleMode() {
.badge-ok { background: rgba(72,187,120,0.15); color: var(--success); } .badge-ok { background: rgba(72,187,120,0.15); color: var(--success); }
.badge-err { background: rgba(252,129,129,0.15); color: var(--danger); animation: pulse 1.5s infinite; } .badge-err { background: rgba(252,129,129,0.15); color: var(--danger); animation: pulse 1.5s infinite; }
.badge-info { background: rgba(79,156,249,0.12); color: var(--accent); } .badge-info { background: rgba(79,156,249,0.12); color: var(--accent); }
.badge-mode { cursor: pointer; transition: background 0.2s; }
.badge-mode:hover { opacity: 0.8; }
.mode-sender { background: rgba(72,187,120,0.2); color: var(--success); }
.mode-responder { background: rgba(246,173,85,0.2); color: var(--warning); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } } @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
</style> </style>

View File

@ -13,23 +13,13 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Destination IP</label> <label>Base Flow</label>
<input v-model="form.dst_ip" placeholder="10.100.0.1" /> <select v-model="form.flow_id">
</div> <option value="" disabled>Select a flow...</option>
<option v-for="f in flows" :key="f.id" :value="f.id">
<div class="form-row-pair"> {{ f.name || f.dst_ip }} ({{ f.protocol }})
<div class="form-row"> </option>
<label>Protocol</label> </select>
<select v-model="form.protocol">
<option value="udp">UDP</option>
<option value="icmp">ICMP</option>
<option value="tcp">TCP</option>
</select>
</div>
<div class="form-row">
<label>Source IP</label>
<input v-model="form.src_ip" placeholder="auto" />
</div>
</div> </div>
<div class="form-row"> <div class="form-row">
@ -47,13 +37,8 @@
<input v-model.number="form.trial_duration" type="number" min="5" max="300" /> <input v-model.number="form.trial_duration" type="number" min="5" max="300" />
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Max Rate</label> <label>Max Rate (pps)</label>
<input v-model.number="form.max_rate_val" type="number" min="1" step="any" /> <input v-model.number="form.max_rate_pps" type="number" min="10" max="100000" />
<select v-model="form.max_rate_unit" class="rate-unit-standalone">
<option value="pps">pps</option>
<option value="kbps">Kbps</option>
<option value="mbps">Mbps</option>
</select>
</div> </div>
</div> </div>
@ -62,7 +47,7 @@
<input v-model.number="form.acceptable_loss_pct" type="number" min="0" max="100" step="0.1" /> <input v-model.number="form.acceptable_loss_pct" type="number" min="0" max="100" step="0.1" />
</div> </div>
<button class="btn btn-accent" @click="create" :disabled="!form.dst_ip"> <button class="btn btn-accent" @click="create" :disabled="!form.flow_id">
Create & Run Test Create & Run Test
</button> </button>
@ -82,60 +67,36 @@
import { reactive, ref, onMounted } from 'vue' import { reactive, ref, onMounted } from 'vue'
import { api } from '../api.js' import { api } from '../api.js'
const props = defineProps({ flows: Array })
const emit = defineEmits(['created', 'refresh']) const emit = defineEmits(['created', 'refresh'])
const standardSizes = [64, 128, 256, 512, 1024, 1280, 1518, 2048, 4096, 9000] const standardSizes = [64, 128, 256, 512, 1024, 1280, 1518]
const presets = ref({}) const presets = ref({})
const form = reactive({ const form = reactive({
type: 'throughput', type: 'throughput',
dst_ip: '', flow_id: '',
src_ip: '',
protocol: 'udp',
frame_sizes: [64, 512, 1518], frame_sizes: [64, 512, 1518],
trial_duration: 30, trial_duration: 30,
max_rate_val: 10, max_rate_pps: 10000,
max_rate_unit: 'mbps',
acceptable_loss_pct: 0.0, acceptable_loss_pct: 0.0,
}) })
function computePps(val, unit) {
if (unit === 'kbps') return Math.max(1, Math.round((val * 1000) / (512 * 8)))
if (unit === 'mbps') return Math.max(1, Math.round((val * 1_000_000) / (512 * 8)))
return Math.round(val)
}
onMounted(async () => { onMounted(async () => {
try { const r = await api.presets(); presets.value = r.presets || r } catch (_) {} try { const r = await api.presets(); presets.value = r.presets || r } catch (_) {}
}) })
async function create() { async function create() {
try { try {
const payload = { const test = await api.createTest({ ...form })
type: form.type,
flow_config: {
dst_ip: form.dst_ip,
src_ip: form.src_ip || 'auto',
protocol: form.protocol,
src_port: 50000,
dst_port: 5001,
},
frame_sizes: form.frame_sizes,
trial_duration: form.trial_duration,
max_rate_pps: computePps(form.max_rate_val, form.max_rate_unit),
acceptable_loss_pct: form.acceptable_loss_pct,
}
const test = await api.createTest(payload)
await api.startTest(test.id) await api.startTest(test.id)
emit('created') emit('created')
} catch (e) { alert(e.message) } } catch (e) { alert(e.message) }
} }
async function loadPreset(name) { async function loadPreset(name) {
const dstIp = prompt('Destination IP for this preset:', '10.100.0.100')
if (!dstIp) return
try { try {
await api.loadPreset(name, { dst_ip: dstIp }) await api.loadPreset(name, {})
emit('refresh') emit('refresh')
} catch (e) { alert(e.message) } } catch (e) { alert(e.message) }
} }
@ -148,7 +109,6 @@ h4 { font-size: 13px; margin: 16px 0 8px; color: var(--muted); }
.form-row label { display: block; font-size: 11px; color: var(--muted); margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.05em; } .form-row label { display: block; font-size: 11px; color: var(--muted); margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.05em; }
.form-row input, .form-row select { width: 100%; } .form-row input, .form-row select { width: 100%; }
.form-row-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .form-row-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.rate-unit-standalone { width: 100%; margin-top: 4px; }
.frame-sizes { display: flex; flex-wrap: wrap; gap: 8px; } .frame-sizes { display: flex; flex-wrap: wrap; gap: 8px; }
.checkbox-label { font-size: 12px; display: flex; align-items: center; gap: 4px; color: var(--text); cursor: pointer; } .checkbox-label { font-size: 12px; display: flex; align-items: center; gap: 4px; color: var(--text); cursor: pointer; }
.btn { padding: 8px 16px; font-weight: 600; font-size: 13px; width: 100%; margin-top: 8px; } .btn { padding: 8px 16px; font-weight: 600; font-size: 13px; width: 100%; margin-top: 8px; }

View File

@ -1,145 +1,67 @@
<template> <template>
<div class="test-runner"> <div class="test-runner">
<h3>Running Tests</h3> <div v-if="!tests.length" class="empty">No tests created yet. Use the Test Builder tab.</div>
<div v-if="!tests.length" class="empty">No tests yet. Create one above and click "Create & Run Test".</div> <div v-for="t in tests" :key="t.id" class="test-card" :class="'state-' + t.state">
<div v-for="t in sortedTests" :key="t.id" class="test-card" :class="'state-' + t.state">
<div class="test-header"> <div class="test-header">
<div class="test-title"> <div>
<strong>{{ t.type }}</strong> <strong>{{ t.type }}</strong>
<span class="test-state" :class="'ts-' + t.state">{{ t.state }}</span> <span class="test-state">{{ t.state }}</span>
<span v-if="t.frame_sizes" class="test-sizes">{{ t.frame_sizes.length }} frame sizes</span>
</div> </div>
<div class="test-actions"> <div class="test-actions">
<button v-if="t.state === 'idle'" class="btn-sm btn-go" @click="start(t.id)">Start</button> <button v-if="t.state === 'idle'" class="btn-sm btn-go" @click="start(t.id)">Start</button>
<button v-if="t.state === 'running'" class="btn-sm btn-stop" @click="stop(t.id)">Stop</button> <button v-if="t.state === 'running'" class="btn-sm btn-stop" @click="stop(t.id)">Stop</button>
<button v-if="t.state === 'complete' || t.state === 'error'" class="btn-sm btn-del" @click="del(t.id)">Remove</button>
</div> </div>
</div> </div>
<!-- RUNNING: live progress -->
<div v-if="t.state === 'running'" class="progress-section"> <div v-if="t.state === 'running'" class="progress-section">
<div class="progress-detail"> <div class="progress-label">Running {{ t.type }} test...</div>
<span v-if="t.progress">{{ t.progress.message }}</span>
<span v-else>Starting...</span>
<span v-if="t.progress" class="progress-counter">
{{ (t.progress.completed_sizes || []).length }}/{{ t.progress.total_frames }} sizes done
</span>
</div>
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-fill" :style="{ width: progressPct(t) + '%' }"></div> <div class="progress-fill" :style="{ width: progressPct(t) + '%' }"></div>
</div> </div>
<!-- Show partial results as they come in -->
<div v-if="t.results && Object.keys(t.results).length" class="partial-results">
<div v-for="(val, size) in t.results" :key="size" class="partial-item">
<span class="partial-size">{{ size }}B</span>
<span class="partial-val" v-if="!Array.isArray(val) && val.max_throughput_pps != null">{{ val.max_throughput_pps }} pps</span>
<span class="partial-val" v-else-if="!Array.isArray(val) && val.avg_ms != null">{{ val.avg_ms }}ms avg</span>
<span class="partial-val" v-else-if="!Array.isArray(val) && val.max_burst_frames != null">{{ val.max_burst_frames }} frames</span>
<span class="partial-val" v-else-if="Array.isArray(val)">{{ val.length }} rate steps</span>
</div>
</div>
</div> </div>
<!-- ERROR --> <div v-if="t.state === 'complete' && t.results" class="results-preview">
<div v-if="t.state === 'error'" class="error-msg">{{ t.error || 'Test failed' }}</div> <table>
<!-- COMPLETE: inline results summary -->
<div v-if="t.state === 'complete' && t.results && Object.keys(t.results).length" class="results-preview">
<!-- Frame Loss has a different structure (array per size) -->
<template v-if="t.type === 'frame_loss'">
<div v-for="(rates, size) in t.results" :key="size" class="fl-section">
<div class="fl-title">Frame Size: {{ size }} B</div>
<table>
<thead>
<tr>
<th>Rate %</th>
<th>Rate (pps)</th>
<th>TX Packets</th>
<th>RX Packets</th>
<th>Loss %</th>
</tr>
</thead>
<tbody>
<tr v-for="r in rates" :key="r.rate_pct">
<td class="mono">{{ r.rate_pct }}%</td>
<td class="mono">{{ r.rate_pps }}</td>
<td class="mono">{{ r.tx_packets }}</td>
<td class="mono">{{ r.rx_packets }}</td>
<td class="mono">{{ r.loss_pct }}%</td>
</tr>
</tbody>
</table>
</div>
</template>
<!-- Other test types: single value per size -->
<table v-else>
<thead> <thead>
<tr> <tr>
<th>Frame Size</th> <th>Frame Size</th>
<th v-if="t.type === 'throughput'">Max Rate (pps)</th> <th v-if="t.type === 'throughput'">Max Rate (pps)</th>
<th v-if="t.type === 'throughput'">Throughput</th> <th v-if="t.type === 'latency'">Avg Latency (ms)</th>
<th v-if="t.type === 'latency'">Avg (ms)</th> <th v-if="t.type === 'frame_loss'">Loss @ Max %</th>
<th v-if="t.type === 'latency'">Min/Max (ms)</th>
<th v-if="t.type === 'back_to_back'">Max Burst</th> <th v-if="t.type === 'back_to_back'">Max Burst</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(val, size) in t.results" :key="size"> <tr v-for="(val, size) in t.results" :key="size">
<td>{{ size }} B</td> <td>{{ size }} B</td>
<td v-if="t.type === 'throughput'" class="mono">{{ val.max_throughput_pps || '-' }}</td> <td v-if="t.type === 'throughput'">{{ val.max_rate_pps || val }}</td>
<td v-if="t.type === 'throughput'" class="mono">{{ formatMbps(val) }}</td> <td v-if="t.type === 'latency'">{{ val.avg_ms || val }}</td>
<td v-if="t.type === 'latency'" class="mono">{{ val.avg_ms != null ? val.avg_ms.toFixed(2) : '-' }}</td> <td v-if="t.type === 'frame_loss'">{{ val.loss_pct || val }}%</td>
<td v-if="t.type === 'latency'" class="mono">{{ val.min_ms != null ? val.min_ms.toFixed(2) + ' / ' + val.max_ms.toFixed(2) : '-' }}</td> <td v-if="t.type === 'back_to_back'">{{ val.max_burst || val }}</td>
<td v-if="t.type === 'back_to_back'" class="mono">{{ val.max_burst_frames ?? '-' }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="test-meta"> <div class="test-meta">
<span>Created: {{ t.created_at || '-' }}</span>
<span v-if="t.started_at">Started: {{ t.started_at }}</span> <span v-if="t.started_at">Started: {{ t.started_at }}</span>
<span v-if="t.completed_at">Completed: {{ t.completed_at }}</span> <span v-if="t.completed_at">Completed: {{ t.completed_at }}</span>
<span v-if="t.state === 'running' && t.started_at">Elapsed: {{ elapsed(t) }}</span>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'
import { api } from '../api.js' import { api } from '../api.js'
const props = defineProps({ tests: Array }) const props = defineProps({ tests: Array })
const emit = defineEmits(['refresh']) const emit = defineEmits(['refresh'])
const sortedTests = computed(() => {
const order = { running: 0, idle: 1, complete: 2, error: 3 }
return [...(props.tests || [])].sort((a, b) => (order[a.state] ?? 9) - (order[b.state] ?? 9))
})
function progressPct(t) { function progressPct(t) {
if (!t.progress || !t.progress.total_frames) return 10 if (!t.results || !t.frame_sizes) return 20
const done = (t.progress.completed_sizes || []).length const done = Object.keys(t.results).length
const partial = t.progress.frame_idx > done ? 0.5 : 0 return Math.min(95, (done / t.frame_sizes.length) * 100)
return Math.min(95, ((done + partial) / t.progress.total_frames) * 100)
}
function formatMbps(val) {
const pps = val.max_throughput_pps || 0
const fs = val.frame_size || 64
if (!pps) return '-'
const mbps = (pps * fs * 8) / 1_000_000
return mbps.toFixed(1) + ' Mbps'
}
function elapsed(t) {
if (!t.started_at) return ''
const start = new Date(t.started_at).getTime()
const secs = Math.round((Date.now() - start) / 1000)
const m = Math.floor(secs / 60)
const s = secs % 60
return m > 0 ? `${m}m ${s}s` : `${s}s`
} }
async function start(id) { async function start(id) {
@ -148,48 +70,28 @@ async function start(id) {
async function stop(id) { async function stop(id) {
try { await api.stopTest(id); emit('refresh') } catch (e) { alert(e.message) } try { await api.stopTest(id); emit('refresh') } catch (e) { alert(e.message) }
} }
async function del(id) {
emit('refresh')
}
</script> </script>
<style scoped> <style scoped>
h3 { font-size: 15px; margin-bottom: 12px; color: var(--accent); } .empty { color: var(--muted); padding: 20px; text-align: center; }
.empty { color: var(--muted); padding: 16px; text-align: center; font-size: 13px; }
.test-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; margin-bottom: 10px; } .test-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; margin-bottom: 10px; }
.test-card.state-running { border-color: var(--accent); } .test-card.state-running { border-color: var(--accent); }
.test-card.state-complete { border-color: var(--success); } .test-card.state-complete { border-color: var(--success); }
.test-card.state-error { border-color: var(--danger); } .test-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.test-header { display: flex; justify-content: space-between; align-items: center; } .test-header strong { font-size: 14px; text-transform: capitalize; }
.test-title { display: flex; align-items: center; gap: 8px; } .test-state { font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-left: 8px; font-weight: 600; background: rgba(113,128,150,0.2); color: var(--muted); }
.test-title strong { font-size: 14px; text-transform: capitalize; } .state-running .test-state { background: rgba(79,156,249,0.15); color: var(--accent); }
.test-state { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 600; } .state-complete .test-state { background: rgba(72,187,120,0.15); color: var(--success); }
.ts-idle { background: rgba(113,128,150,0.2); color: var(--muted); }
.ts-running { background: rgba(79,156,249,0.15); color: var(--accent); }
.ts-complete { background: rgba(72,187,120,0.15); color: var(--success); }
.ts-error { background: rgba(252,129,129,0.15); color: var(--danger); }
.test-sizes { font-size: 11px; color: var(--muted); }
.test-actions { display: flex; gap: 4px; } .test-actions { display: flex; gap: 4px; }
.btn-sm { padding: 3px 10px; font-size: 11px; font-weight: 600; border-radius: 6px; } .btn-sm { padding: 3px 10px; font-size: 11px; font-weight: 600; border-radius: 6px; }
.btn-go { background: var(--success); color: #fff; } .btn-go { background: var(--success); color: #fff; }
.btn-stop { background: var(--warning); color: #000; } .btn-stop { background: var(--warning); color: #000; }
.btn-del { background: rgba(252,129,129,0.15); color: var(--danger); } .progress-section { margin: 8px 0; }
.progress-section { margin: 10px 0; } .progress-label { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.progress-detail { display: flex; justify-content: space-between; font-size: 12px; color: var(--muted); margin-bottom: 6px; font-family: monospace; }
.progress-counter { color: var(--accent); font-weight: 600; }
.progress-bar { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; } .progress-bar { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.5s; } .progress-fill { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.5s; }
.partial-results { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; } .results-preview table { width: 100%; border-collapse: collapse; margin-top: 8px; }
.partial-item { background: rgba(72,187,120,0.1); border: 1px solid rgba(72,187,120,0.2); padding: 2px 8px; border-radius: 6px; font-size: 11px; }
.partial-size { font-weight: 600; color: var(--success); }
.partial-val { color: var(--text); margin-left: 4px; font-family: monospace; }
.error-msg { color: var(--danger); font-size: 13px; padding: 8px 0; }
.results-preview { margin-top: 10px; }
.results-preview table { width: 100%; border-collapse: collapse; }
.results-preview th { font-size: 11px; color: var(--muted); text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--border); } .results-preview th { font-size: 11px; color: var(--muted); text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--border); }
.results-preview td { font-size: 13px; padding: 4px 8px; } .results-preview td { font-size: 13px; padding: 4px 8px; font-family: monospace; }
.mono { font-family: monospace; }
.fl-section { margin-bottom: 12px; }
.fl-title { font-size: 12px; font-weight: 600; color: var(--accent); margin-bottom: 4px; }
.test-meta { display: flex; gap: 12px; margin-top: 8px; font-size: 11px; color: var(--muted); } .test-meta { display: flex; gap: 12px; margin-top: 8px; font-size: 11px; color: var(--muted); }
</style> </style>

View File

@ -2,7 +2,6 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
export default defineConfig({ export default defineConfig({
base: '/traffic/',
plugins: [vue()], plugins: [vue()],
server: { server: {
proxy: { proxy: {

View File

@ -1,6 +1,6 @@
FROM python:3.11-slim FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
tcpreplay libpcap-dev procps iputils-ping && rm -rf /var/lib/apt/lists/* tcpreplay libpcap-dev procps && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir flask scapy psutil RUN pip install --no-cache-dir flask scapy psutil
COPY . /traffic-gen/ COPY . /traffic-gen/
WORKDIR /traffic-gen WORKDIR /traffic-gen

View File

@ -87,7 +87,7 @@ def build_packet(flow_config: dict, seq: int = 0):
# --- Layer 3 --- # --- Layer 3 ---
ip_kwargs = {'dst': flow_config['dst_ip']} ip_kwargs = {'dst': flow_config['dst_ip']}
src_ip = flow_config.get('src_ip') src_ip = flow_config.get('src_ip')
if src_ip and src_ip != 'auto': if src_ip:
ip_kwargs['src'] = src_ip ip_kwargs['src'] = src_ip
dscp = flow_config.get('dscp', 0) dscp = flow_config.get('dscp', 0)
@ -99,13 +99,13 @@ def build_packet(flow_config: dict, seq: int = 0):
# --- Layer 4 --- # --- Layer 4 ---
if protocol == 'udp': if protocol == 'udp':
src_port = flow_config.get('src_port') or 12000 src_port = flow_config.get('src_port', 12000)
dst_port = flow_config.get('dst_port') or 5001 dst_port = flow_config.get('dst_port', 5001)
pkt = pkt / UDP(sport=int(src_port), dport=int(dst_port)) pkt = pkt / UDP(sport=int(src_port), dport=int(dst_port))
header_overhead += 8 header_overhead += 8
elif protocol == 'tcp': elif protocol == 'tcp':
src_port = flow_config.get('src_port') or 12000 src_port = flow_config.get('src_port', 12000)
dst_port = flow_config.get('dst_port') or 80 dst_port = flow_config.get('dst_port', 80)
pkt = pkt / TCP(sport=int(src_port), dport=int(dst_port), flags='S') pkt = pkt / TCP(sport=int(src_port), dport=int(dst_port), flags='S')
header_overhead += 20 header_overhead += 20
elif protocol == 'icmp': elif protocol == 'icmp':

View File

@ -1,204 +1,185 @@
""" """
Responder - high-performance UDP packet receiver for TGEN traffic. Responder - listens for TGEN-tagged packets and collects receive statistics.
Uses multiple receiver threads on SO_REUSEPORT UDP sockets for parallel Two sub-modes:
packet processing. Each thread has its own socket and stats counters - echo: swaps src/dst MAC and IP, sends packet back with receive timestamp
to avoid contention. - log: records rx stats only, exposed via API
""" """
import logging import logging
import os
import socket
import struct import struct
import threading import threading
import time import time
from engine.packet_builder import MAGIC, HEADER_LEN from scapy.all import (
AsyncSniffer, Ether, IP, Raw, send, conf,
)
from engine.packet_builder import MAGIC, HEADER_LEN, parse_payload
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
conf.verb = 0
DEFAULT_LISTEN_PORT = 5001
RECV_BUF_SIZE = 16 * 1024 * 1024
NUM_WORKERS = int(os.environ.get('RESPONDER_WORKERS', 4))
class _WorkerStats:
"""Per-worker stats — no sharing, no locks."""
__slots__ = ('rx_packets', 'rx_bytes', 'out_of_order', 'duplicates',
'last_seq', 'lat_buf', 'lat_idx', 'lat_count')
def __init__(self):
self.rx_packets = 0
self.rx_bytes = 0
self.out_of_order = 0
self.duplicates = 0
self.last_seq = -1
self.lat_buf = [0.0] * 4096
self.lat_idx = 0
self.lat_count = 0
class Responder: class Responder:
def __init__(self, mode: str = 'log', listen_port: int = DEFAULT_LISTEN_PORT): """Listens for TGEN packets on an interface and collects stats."""
def __init__(self, mode: str = 'log'):
"""
Args:
mode: 'echo' to reflect packets back, 'log' to only record stats.
"""
self._mode = mode self._mode = mode
self._listen_port = listen_port self._lock = threading.Lock()
self._sockets = [] self._sniffer = None
self._threads = []
self._workers = []
self._running = False self._running = False
self._stop_event = threading.Event()
# Stats
self._rx_packets = 0
self._rx_bytes = 0
self._latency_samples = [] # list of (latency_ms,)
self._seen_seqs = set()
self._last_seq = -1
self._out_of_order = 0
self._duplicates = 0
# ------------------------------------------------------------------
# Control
# ------------------------------------------------------------------
def start(self, interface: str = None): def start(self, interface: str = None):
"""Start sniffing for TGEN packets."""
if self._running: if self._running:
log.warning('Responder already running')
return return
self._stop_event.clear() bpf_filter = 'ip' # broad filter; we check magic in callback
n = NUM_WORKERS kwargs = {
'prn': self._handle_packet,
'store': False,
'filter': bpf_filter,
}
if interface:
kwargs['iface'] = interface
for i in range(n): self._sniffer = AsyncSniffer(**kwargs)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self._sniffer.start()
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, RECV_BUF_SIZE)
except OSError:
pass
sock.settimeout(0.5)
sock.bind(('0.0.0.0', self._listen_port))
self._sockets.append(sock)
ws = _WorkerStats()
self._workers.append(ws)
t = threading.Thread(target=self._recv_loop, args=(sock, ws),
daemon=True, name=f'responder-rx-{i}')
self._threads.append(t)
t.start()
actual_buf = self._sockets[0].getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)
self._running = True self._running = True
log.info('Responder started on port=%d mode=%s workers=%d rcvbuf=%d', log.info('Responder started on interface=%s mode=%s', interface or 'all', self._mode)
self._listen_port, self._mode, n, actual_buf)
def stop(self): def stop(self):
self._stop_event.set() """Stop sniffing."""
for t in self._threads: if self._sniffer and self._running:
if t.is_alive(): self._sniffer.stop()
t.join(timeout=3) self._running = False
for s in self._sockets: log.info('Responder stopped')
try:
s.close()
except Exception:
pass
self._sockets.clear()
self._threads.clear()
self._workers.clear()
self._running = False
log.info('Responder stopped')
def is_running(self) -> bool: def is_running(self) -> bool:
return self._running return self._running
# ------------------------------------------------------------------
# Stats
# ------------------------------------------------------------------
def get_stats(self) -> dict: def get_stats(self) -> dict:
rx_packets = 0 with self._lock:
rx_bytes = 0 latency = {}
out_of_order = 0 if self._latency_samples:
duplicates = 0 vals = self._latency_samples
all_lat = [] latency = {
'min_ms': round(min(vals), 3),
for ws in self._workers: 'max_ms': round(max(vals), 3),
rx_packets += ws.rx_packets 'avg_ms': round(sum(vals) / len(vals), 3),
rx_bytes += ws.rx_bytes 'jitter_ms': round(
out_of_order += ws.out_of_order sum(abs(vals[i] - vals[i - 1]) for i in range(1, len(vals))) / max(1, len(vals) - 1),
duplicates += ws.duplicates 3
n = min(ws.lat_count, len(ws.lat_buf)) ) if len(vals) > 1 else 0.0,
if n > 0: 'samples': len(vals),
all_lat.extend(ws.lat_buf[:n] if ws.lat_count <= len(ws.lat_buf) else ws.lat_buf[:]) }
return {
latency = {} 'rx_packets': self._rx_packets,
if all_lat: 'rx_bytes': self._rx_bytes,
avg = sum(all_lat) / len(all_lat) 'out_of_order': self._out_of_order,
mn = min(all_lat) 'duplicates': self._duplicates,
mx = max(all_lat) 'latency': latency,
jitter = 0.0 'running': self._running,
if len(all_lat) > 1:
jitter = sum(abs(all_lat[i] - all_lat[i-1]) for i in range(1, len(all_lat))) / (len(all_lat) - 1)
latency = {
'min_ms': round(mn, 3),
'max_ms': round(mx, 3),
'avg_ms': round(avg, 3),
'jitter_ms': round(jitter, 3),
'samples': len(all_lat),
} }
return {
'rx_packets': rx_packets,
'rx_bytes': rx_bytes,
'out_of_order': out_of_order,
'duplicates': duplicates,
'latency': latency,
'running': self._running,
}
def reset_stats(self): def reset_stats(self):
for ws in self._workers: with self._lock:
ws.rx_packets = 0 self._rx_packets = 0
ws.rx_bytes = 0 self._rx_bytes = 0
ws.last_seq = -1 self._latency_samples = []
ws.out_of_order = 0 self._seen_seqs.clear()
ws.duplicates = 0 self._last_seq = -1
ws.lat_idx = 0 self._out_of_order = 0
ws.lat_count = 0 self._duplicates = 0
log.info('Responder stats reset') log.info('Responder stats reset')
def _recv_loop(self, sock, ws): # ------------------------------------------------------------------
"""Per-worker receive loop.""" # Packet handling
echo = self._mode == 'echo' # ------------------------------------------------------------------
recvfrom = sock.recvfrom
time_ns = time.time_ns
stop_is_set = self._stop_event.is_set
lat_buf = ws.lat_buf
lat_buf_len = len(lat_buf)
magic = MAGIC
while not stop_is_set(): def _handle_packet(self, pkt):
"""Process a received packet, checking for TGEN magic bytes."""
if not pkt.haslayer(Raw):
return
payload = bytes(pkt[Raw].load)
parsed = parse_payload(payload)
if parsed is None:
return
seq, sender_ts_ns = parsed
rx_time_ns = time.time_ns()
pkt_len = len(bytes(pkt))
# Compute one-way latency (only meaningful if clocks are synced)
latency_ms = (rx_time_ns - sender_ts_ns) / 1_000_000
with self._lock:
self._rx_packets += 1
self._rx_bytes += pkt_len
# Duplicate detection
if seq in self._seen_seqs:
self._duplicates += 1
else:
self._seen_seqs.add(seq)
# Keep set bounded
if len(self._seen_seqs) > 100000:
# Remove oldest entries (approximate)
to_remove = sorted(self._seen_seqs)[:50000]
self._seen_seqs -= set(to_remove)
# Out-of-order detection
if seq < self._last_seq and seq not in self._seen_seqs:
self._out_of_order += 1
self._last_seq = seq
# Record latency (only if plausible: 0 < latency < 60s)
if 0 < latency_ms < 60000:
self._latency_samples.append(latency_ms)
if len(self._latency_samples) > 10000:
self._latency_samples = self._latency_samples[-5000:]
# Echo mode: swap and send back
if self._mode == 'echo' and pkt.haslayer(Ether) and pkt.haslayer(IP):
try: try:
data, addr = recvfrom(65535) echo_pkt = pkt.copy()
except socket.timeout: # Swap MACs
continue echo_pkt[Ether].src, echo_pkt[Ether].dst = pkt[Ether].dst, pkt[Ether].src
except OSError: # Swap IPs
if stop_is_set(): echo_pkt[IP].src, echo_pkt[IP].dst = pkt[IP].dst, pkt[IP].src
break # Append receive timestamp to payload
raise rx_ts_bytes = struct.pack('!Q', rx_time_ns)
echo_pkt[Raw].load = payload + rx_ts_bytes
rx_ns = time_ns() # Clear checksums so Scapy recalculates
dlen = len(data) del echo_pkt[IP].chksum
if echo_pkt.haslayer('UDP'):
if dlen < HEADER_LEN or data[:4] != magic: del echo_pkt['UDP'].chksum
continue elif echo_pkt.haslayer('TCP'):
del echo_pkt['TCP'].chksum
seq = int.from_bytes(data[4:8], 'big') send(echo_pkt[IP], verbose=0)
sender_ns = int.from_bytes(data[8:16], 'big') except Exception as e:
log.debug('Echo send error: %s', e)
ws.rx_packets += 1
ws.rx_bytes += dlen
last = ws.last_seq
if seq == last:
ws.duplicates += 1
elif seq < last:
ws.out_of_order += 1
ws.last_seq = seq
lat_ms = (rx_ns - sender_ns) / 1_000_000
if 0 < lat_ms < 60000:
idx = ws.lat_idx
lat_buf[idx] = lat_ms
ws.lat_idx = (idx + 1) % lat_buf_len
ws.lat_count += 1
if echo:
try:
sock.sendto(data + struct.pack('!Q', rx_ns), addr)
except Exception:
pass

View File

@ -7,17 +7,13 @@ RFC 2544 test implementations:
""" """
import logging import logging
import socket
import struct
import threading import threading
import time import time
import urllib.request
import json
from typing import Dict, List, Optional from typing import Dict, List, Optional
from scapy.all import send, sr, conf, IP, ICMP from scapy.all import send, sr, conf, IP, ICMP
from engine.packet_builder import build_packet, parse_payload, MAGIC from engine.packet_builder import build_packet, parse_payload
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
conf.verb = 0 conf.verb = 0
@ -28,14 +24,13 @@ class _BaseTest:
def __init__(self, test_id: str, flow_config: dict, frame_sizes: List[int], def __init__(self, test_id: str, flow_config: dict, frame_sizes: List[int],
trial_duration: float = 60, max_rate_pps: int = 10000, trial_duration: float = 60, max_rate_pps: int = 10000,
acceptable_loss_pct: float = 0.0, responder_url: str = None): acceptable_loss_pct: float = 0.0):
self.test_id = test_id self.test_id = test_id
self.flow_config = dict(flow_config) self.flow_config = dict(flow_config)
self.frame_sizes = frame_sizes self.frame_sizes = frame_sizes
self.trial_duration = trial_duration self.trial_duration = trial_duration
self.max_rate_pps = max_rate_pps self.max_rate_pps = max_rate_pps
self.acceptable_loss_pct = acceptable_loss_pct self.acceptable_loss_pct = acceptable_loss_pct
self.responder_url = responder_url # e.g. "http://172.30.0.10:5053"
self.state = 'idle' # idle -> running -> complete/error self.state = 'idle' # idle -> running -> complete/error
self.results = {} self.results = {}
@ -43,11 +38,6 @@ class _BaseTest:
self.started_at = None self.started_at = None
self.completed_at = None self.completed_at = None
# Progress tracking
self._progress_msg = ''
self._current_frame_idx = 0
self._current_trial_tx = 0
self._thread: Optional[threading.Thread] = None self._thread: Optional[threading.Thread] = None
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._lock = threading.Lock() self._lock = threading.Lock()
@ -90,155 +80,72 @@ class _BaseTest:
def _is_stopped(self) -> bool: def _is_stopped(self) -> bool:
return self._stop_event.is_set() return self._stop_event.is_set()
def _responder_reset(self):
"""Reset responder stats before a trial."""
if not self.responder_url:
return
try:
req = urllib.request.Request(
f'{self.responder_url}/responder/reset', method='POST',
data=b'{}', headers={'Content-Type': 'application/json'})
urllib.request.urlopen(req, timeout=3)
except Exception as e:
log.warning('Responder reset failed: %s', e)
def _responder_stats(self) -> Optional[dict]:
"""Query responder for rx stats after a trial."""
if not self.responder_url:
return None
try:
req = urllib.request.Request(f'{self.responder_url}/responder/stats')
resp = urllib.request.urlopen(req, timeout=5)
return json.loads(resp.read())
except Exception as e:
log.warning('Responder stats query failed: %s', e)
return None
def _send_trial(self, frame_size: int, rate_pps: int, duration: float): def _send_trial(self, frame_size: int, rate_pps: int, duration: float):
"""Send packets at a given rate for a duration. Returns (tx_count, rx_count, latencies).""" """Send packets at a given rate for a duration. Returns (tx_count, rx_count, latencies)."""
flow = dict(self.flow_config) flow = dict(self.flow_config)
flow['frame_size'] = frame_size flow['frame_size'] = frame_size
protocol = flow.get('protocol', 'udp').lower()
# Reset responder counters before trial
self._responder_reset()
interval = 1.0 / rate_pps if rate_pps > 0 else 1.0
tx_count = 0 tx_count = 0
rx_count = 0 rx_count = 0
latencies = [] latencies = []
start = time.time() protocol = flow.get('protocol', 'udp').lower()
if protocol == 'icmp': start = time.time()
# ICMP: use sr() to measure latency from responses seq = 0
seq = 0
while (time.time() - start) < duration and not self._is_stopped(): while (time.time() - start) < duration and not self._is_stopped():
pkt = build_packet(flow, seq=seq) pkt = build_packet(flow, seq=seq)
seq += 1 seq += 1
if protocol == 'icmp':
# Use sr() for ICMP to get responses
answered, _ = sr(pkt[IP], timeout=1, verbose=0) answered, _ = sr(pkt[IP], timeout=1, verbose=0)
tx_count += 1 tx_count += 1
for sent_pkt, recv_pkt in answered: for sent_pkt, recv_pkt in answered:
rx_count += 1 rx_count += 1
rtt_ms = (recv_pkt.time - sent_pkt.sent_time) * 1000 rtt_ms = (recv_pkt.time - sent_pkt.sent_time) * 1000
latencies.append(rtt_ms) latencies.append(rtt_ms)
elapsed = time.time() - start else:
expected = elapsed * rate_pps send(pkt[IP], verbose=0)
if tx_count > expected: tx_count += 1
sleep_time = (tx_count - expected) / rate_pps
if sleep_time > 0:
self._stop_event.wait(min(sleep_time, 0.1))
else:
# UDP/TCP: high-performance raw socket path
dst_ip = flow['dst_ip']
pkt_template = build_packet(flow, seq=0)
ip_template = bytes(pkt_template[pkt_template.firstlayer().payload.__class__])
magic_offset = ip_template.find(MAGIC)
# Find and zero UDP checksum in template so receivers accept packets # Rate limiting
# IP header length from IHL field (byte 0, low nibble) * 4 elapsed = time.time() - start
ip_ihl = (ip_template[0] & 0x0F) * 4 expected_sent = elapsed * rate_pps
ip_proto = ip_template[9] # protocol field if tx_count > expected_sent:
udp_csum_offset = ip_ihl + 6 if ip_proto == 17 else -1 # 17 = UDP sleep_time = (tx_count - expected_sent) / rate_pps
if sleep_time > 0:
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW) self._stop_event.wait(min(sleep_time, 0.1))
raw_sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1)
batch_size = max(1, min(rate_pps // 5, 500))
interval = batch_size / rate_pps if rate_pps > 0 else 1.0
seq = 0
try:
while (time.time() - start) < duration and not self._is_stopped():
batch_start = time.time()
for _ in range(batch_size):
pkt_bytes = bytearray(ip_template)
if magic_offset >= 0:
struct.pack_into('!I', pkt_bytes, magic_offset + 4, seq)
struct.pack_into('!Q', pkt_bytes, magic_offset + 8, time.time_ns())
pkt_bytes[10:12] = b'\x00\x00' # zero IP checksum
if udp_csum_offset > 0:
pkt_bytes[udp_csum_offset:udp_csum_offset + 2] = b'\x00\x00'
try:
raw_sock.sendto(bytes(pkt_bytes), (dst_ip, 0))
tx_count += 1
except Exception:
pass
seq += 1
batch_elapsed = time.time() - batch_start
sleep_time = interval - batch_elapsed
if sleep_time > 0:
self._stop_event.wait(sleep_time)
finally:
raw_sock.close()
# Query responder for actual rx stats (UDP/TCP path)
if protocol != 'icmp':
resp_stats = self._responder_stats()
if resp_stats and resp_stats.get('rx_packets', 0) > 0:
rx_count = resp_stats['rx_packets']
lat = resp_stats.get('latency', {})
if lat.get('samples', 0) > 0:
latencies = [lat['avg_ms']] # Use avg as representative
# For non-ICMP, we can't easily measure rx without a responder.
# rx_count stays 0 for UDP/TCP unless a responder is configured.
return tx_count, rx_count, latencies return tx_count, rx_count, latencies
def get_info(self) -> dict: def get_info(self) -> dict:
# Reverse-lookup the slug for this test class return {
type_slug = next((k for k, v in TEST_TYPES.items() if v is self.__class__), self.__class__.__name__)
info = {
'id': self.test_id,
'test_id': self.test_id, 'test_id': self.test_id,
'type': type_slug, 'type': self.__class__.__name__,
'state': self.state, 'state': self.state,
'results': self.results, 'results': self.results,
'error': self.error, 'error': self.error,
'frame_sizes': self.frame_sizes,
'started_at': self.started_at, 'started_at': self.started_at,
'completed_at': self.completed_at, 'completed_at': self.completed_at,
} }
if self.state == 'running':
info['progress'] = {
'frame_idx': self._current_frame_idx,
'total_frames': len(self.frame_sizes),
'message': self._progress_msg,
'completed_sizes': list(self.results.keys()),
}
return info
class ThroughputTest(_BaseTest): class ThroughputTest(_BaseTest):
"""Binary search for maximum throughput with acceptable loss.""" """Binary search for maximum throughput with acceptable loss."""
def _run(self): def _run(self):
for idx, fs in enumerate(self.frame_sizes): for fs in self.frame_sizes:
if self._is_stopped(): if self._is_stopped():
break break
self._current_frame_idx = idx
low = 0 low = 0
high = self.max_rate_pps high = self.max_rate_pps
best_rate = 0 best_rate = 0
convergence_threshold = max(1, int(self.max_rate_pps * 0.01)) convergence_threshold = max(1, int(self.max_rate_pps * 0.01))
step = 0
log.info('Throughput test: frame_size=%d, searching [%d, %d] pps', fs, low, high) log.info('Throughput test: frame_size=%d, searching [%d, %d] pps', fs, low, high)
@ -246,17 +153,19 @@ class ThroughputTest(_BaseTest):
mid = (low + high) // 2 mid = (low + high) // 2
if mid == 0: if mid == 0:
break break
step += 1
self._progress_msg = f'Frame {fs}B: trial {step}, testing {mid} pps [{low}-{high}]'
tx, rx, _ = self._send_trial(fs, mid, self.trial_duration) tx, rx, _ = self._send_trial(fs, mid, self.trial_duration)
if tx == 0: if tx == 0:
loss_pct = 100.0 loss_pct = 100.0
elif rx > 0:
loss_pct = ((tx - rx) / tx) * 100
else: else:
loss_pct = 0.0 # No responder/ICMP — assume success # For ICMP we have rx; for UDP assume zero loss if no responder
protocol = self.flow_config.get('protocol', 'udp').lower()
if protocol == 'icmp':
loss_pct = ((tx - rx) / tx) * 100
else:
# Without responder, assume success (user should use responder for accurate test)
loss_pct = 0.0
log.info(' frame=%d rate=%d tx=%d rx=%d loss=%.2f%%', log.info(' frame=%d rate=%d tx=%d rx=%d loss=%.2f%%',
fs, mid, tx, rx, loss_pct) fs, mid, tx, rx, loss_pct)
@ -280,12 +189,10 @@ class LatencyTest(_BaseTest):
def _run(self): def _run(self):
rate = self.flow_config.get('rate_pps', 100) rate = self.flow_config.get('rate_pps', 100)
for idx, fs in enumerate(self.frame_sizes): for fs in self.frame_sizes:
if self._is_stopped(): if self._is_stopped():
break break
self._current_frame_idx = idx
self._progress_msg = f'Frame {fs}B: sending at {rate} pps for {self.trial_duration}s'
log.info('Latency test: frame_size=%d at %d pps for %ds', fs, rate, self.trial_duration) log.info('Latency test: frame_size=%d at %d pps for %ds', fs, rate, self.trial_duration)
_, _, latencies = self._send_trial(fs, rate, self.trial_duration) _, _, latencies = self._send_trial(fs, rate, self.trial_duration)
@ -320,11 +227,10 @@ class FrameLossTest(_BaseTest):
"""Measure frame loss at decreasing rates (100%, 90%, 80%, ...).""" """Measure frame loss at decreasing rates (100%, 90%, 80%, ...)."""
def _run(self): def _run(self):
for idx, fs in enumerate(self.frame_sizes): for fs in self.frame_sizes:
if self._is_stopped(): if self._is_stopped():
break break
self._current_frame_idx = idx
results_for_size = [] results_for_size = []
for pct in range(100, 0, -10): for pct in range(100, 0, -10):
if self._is_stopped(): if self._is_stopped():
@ -334,16 +240,14 @@ class FrameLossTest(_BaseTest):
if rate == 0: if rate == 0:
continue continue
self._progress_msg = f'Frame {fs}B: testing at {pct}% rate ({rate} pps)'
log.info('FrameLoss test: frame_size=%d rate=%d (%d%%)', fs, rate, pct) log.info('FrameLoss test: frame_size=%d rate=%d (%d%%)', fs, rate, pct)
tx, rx, _ = self._send_trial(fs, rate, self.trial_duration) tx, rx, _ = self._send_trial(fs, rate, self.trial_duration)
if tx > 0 and rx > 0: protocol = self.flow_config.get('protocol', 'udp').lower()
if tx > 0 and protocol == 'icmp':
loss_pct = ((tx - rx) / tx) * 100 loss_pct = ((tx - rx) / tx) * 100
elif tx > 0 and rx == 0:
loss_pct = 0.0 # No responder — cannot measure
else: else:
loss_pct = 100.0 loss_pct = 0.0 # Cannot measure without responder for non-ICMP
results_for_size.append({ results_for_size.append({
'rate_pct': pct, 'rate_pct': pct,
@ -360,7 +264,7 @@ class BackToBackTest(_BaseTest):
"""Find maximum burst length with zero loss.""" """Find maximum burst length with zero loss."""
def _run(self): def _run(self):
for idx, fs in enumerate(self.frame_sizes): for fs in self.frame_sizes:
if self._is_stopped(): if self._is_stopped():
break break
@ -369,7 +273,6 @@ class BackToBackTest(_BaseTest):
best_burst = 0 best_burst = 0
convergence = max(1, high // 100) convergence = max(1, high // 100)
self._current_frame_idx = idx
log.info('BackToBack test: frame_size=%d searching burst [%d, %d]', fs, low, high) log.info('BackToBack test: frame_size=%d searching burst [%d, %d]', fs, low, high)
while (high - low) > convergence and not self._is_stopped(): while (high - low) > convergence and not self._is_stopped():
@ -396,12 +299,10 @@ class BackToBackTest(_BaseTest):
send(pkt[IP], verbose=0) send(pkt[IP], verbose=0)
tx_count += 1 tx_count += 1
if tx_count > 0 and rx_count > 0: if protocol == 'icmp' and tx_count > 0:
loss_pct = ((tx_count - rx_count) / tx_count) * 100 loss_pct = ((tx_count - rx_count) / tx_count) * 100
elif tx_count > 0:
loss_pct = 0.0 # No responder — cannot measure
else: else:
loss_pct = 100.0 loss_pct = 0.0 # Can't measure without responder
log.info(' burst=%d tx=%d rx=%d loss=%.2f%%', mid, tx_count, rx_count, loss_pct) log.info(' burst=%d tx=%d rx=%d loss=%.2f%%', mid, tx_count, rx_count, loss_pct)

View File

@ -4,8 +4,6 @@ FlowSender - manages traffic generation with background threads per flow.
import logging import logging
import shutil import shutil
import socket
import struct
import threading import threading
import time import time
import urllib.request import urllib.request
@ -13,7 +11,7 @@ import json
from scapy.all import send, sendpfast, sr, conf from scapy.all import send, sendpfast, sr, conf
from engine.packet_builder import build_packet, stamp_payload, MAGIC, HEADER_LEN from engine.packet_builder import build_packet, stamp_payload
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -141,174 +139,105 @@ class FlowSender:
pkt_template = build_packet(flow, seq=0) pkt_template = build_packet(flow, seq=0)
pkt_bytes_len = len(bytes(pkt_template)) pkt_bytes_len = len(bytes(pkt_template))
# Calculate sleep interval: send in batches for efficiency
batch_size = max(1, min(rate_pps // 10, 100))
interval = batch_size / rate_pps if rate_pps > 0 else 1.0
seq = 0 seq = 0
start_time = time.time() start_time = time.time()
last_responder_poll = 0 last_responder_poll = 0
log.info('Flow %s: starting send loop at %d pps for %ds', log.info('Flow %s: starting send loop at %d pps for %ds', flow_id[:8], rate_pps, duration)
flow_id[:8], rate_pps, duration)
# Capture responder baseline so we report deltas, not cumulative totals
responder_baseline_rx = 0
responder_baseline_bytes = 0
if responder_url:
try:
base = self._fetch_responder(responder_url)
responder_baseline_rx = base.get('rx_packets', 0)
responder_baseline_bytes = base.get('rx_bytes', 0)
# Also reset responder so baseline is clean
self._reset_responder(responder_url)
responder_baseline_rx = 0
responder_baseline_bytes = 0
except Exception:
pass
raw_sock = None
try: try:
if use_icmp_sr:
self._send_loop_icmp(flow_id, flow, stop_event, rate_pps, duration)
return
# --- High-performance path: raw socket ---
dst_ip = flow['dst_ip']
# Build template as raw IP bytes (strip Ethernet layer)
ip_template = bytes(pkt_template[pkt_template.firstlayer().payload.__class__])
# Find where TGEN magic starts in the IP-layer bytes
magic_offset = ip_template.find(MAGIC)
# Find and zero UDP checksum offset in template
ip_ihl = (ip_template[0] & 0x0F) * 4
ip_proto = ip_template[9]
udp_csum_offset = ip_ihl + 6 if ip_proto == 17 else -1 # 17 = UDP
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW)
raw_sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1)
# Adaptive batching: send bursts then sleep to hit target rate
batch_size = max(1, min(rate_pps // 5, 500))
interval = batch_size / rate_pps if rate_pps > 0 else 1.0
while not stop_event.is_set(): while not stop_event.is_set():
elapsed = time.time() - start_time elapsed = time.time() - start_time
if duration and elapsed >= duration: if duration and elapsed >= duration:
break break
batch_start = time.time() if use_icmp_sr:
sent_this_batch = 0 # ICMP mode: use sr() to measure latency from responses
pkt = build_packet(flow, seq=seq)
for _ in range(batch_size): answered, _ = sr(pkt[pkt.firstlayer().payload.__class__],
pkt_bytes = bytearray(ip_template) timeout=1, verbose=0)
if magic_offset >= 0: with self._lock:
struct.pack_into('!I', pkt_bytes, magic_offset + 4, seq) stats = self._stats.get(flow_id)
struct.pack_into('!Q', pkt_bytes, magic_offset + 8, time.time_ns()) if stats:
pkt_bytes[10:12] = b'\x00\x00' # zero IP checksum stats['tx_packets'] += 1
if udp_csum_offset > 0: stats['tx_bytes'] += pkt_bytes_len
pkt_bytes[udp_csum_offset:udp_csum_offset + 2] = b'\x00\x00' for sent_pkt, recv_pkt in answered:
try: rtt_ms = (recv_pkt.time - sent_pkt.sent_time) * 1000
raw_sock.sendto(bytes(pkt_bytes), (dst_ip, 0)) stats['rx_packets'] += 1
sent_this_batch += 1 stats['rx_bytes'] += len(bytes(recv_pkt))
except Exception: stats['latency_samples'].append(rtt_ms)
pass # Keep only last 1000 samples
if len(stats['latency_samples']) > 1000:
stats['latency_samples'] = stats['latency_samples'][-1000:]
seq += 1 seq += 1
# Rate limit for ICMP
sleep_time = (1.0 / rate_pps) - (time.time() - start_time - elapsed)
if sleep_time > 0:
stop_event.wait(sleep_time)
else:
# UDP/TCP mode: send batches
packets = []
for _ in range(batch_size):
pkt = build_packet(flow, seq=seq)
packets.append(pkt)
seq += 1
with self._lock: try:
stats = self._stats.get(flow_id) if HAS_TCPREPLAY and rate_pps >= 1000:
if stats: sendpfast(packets, pps=rate_pps, loop=0)
stats['tx_packets'] += sent_this_batch else:
stats['tx_bytes'] += pkt_bytes_len * sent_this_batch for p in packets:
send(p[p.firstlayer().payload.__class__], verbose=0)
except Exception as e:
# Fallback: basic send
log.debug('Send error (falling back): %s', e)
for p in packets:
try:
send(p[p.firstlayer().payload.__class__], verbose=0)
except Exception:
pass
# Poll responder for rx stats periodically with self._lock:
if responder_url and (time.time() - last_responder_poll) >= 1.0: stats = self._stats.get(flow_id)
self._poll_responder(flow_id, responder_url, if stats:
responder_baseline_rx, responder_baseline_bytes) stats['tx_packets'] += len(packets)
last_responder_poll = time.time() stats['tx_bytes'] += pkt_bytes_len * len(packets)
# Precise rate limiting: sleep remaining time for this batch # Poll responder for rx stats periodically
batch_elapsed = time.time() - batch_start if responder_url and (time.time() - last_responder_poll) >= 2.0:
sleep_time = interval - batch_elapsed self._poll_responder(flow_id, responder_url)
if sleep_time > 0: last_responder_poll = time.time()
stop_event.wait(sleep_time)
# Rate limit
stop_event.wait(interval)
except Exception as e: except Exception as e:
log.error('Flow %s: send loop error: %s', flow_id[:8], e) log.error('Flow %s: send loop error: %s', flow_id[:8], e)
finally: finally:
if raw_sock:
raw_sock.close()
with self._lock: with self._lock:
if flow_id in self._flows: if flow_id in self._flows:
self._flows[flow_id]['state'] = 'stopped' self._flows[flow_id]['state'] = 'stopped'
# Final responder poll # Final responder poll
if responder_url: if responder_url:
self._poll_responder(flow_id, responder_url, self._poll_responder(flow_id, responder_url)
responder_baseline_rx, responder_baseline_bytes)
log.info('Flow %s: send loop finished. seq=%d', flow_id[:8], seq) log.info('Flow %s: send loop finished. seq=%d', flow_id[:8], seq)
def _send_loop_icmp(self, flow_id, flow, stop_event, rate_pps, duration): def _poll_responder(self, flow_id: str, responder_url: str):
"""ICMP mode: use sr() to measure latency from router responses."""
pkt_template = build_packet(flow, seq=0)
pkt_bytes_len = len(bytes(pkt_template))
seq = 0
start_time = time.time()
try:
while not stop_event.is_set():
elapsed = time.time() - start_time
if duration and elapsed >= duration:
break
pkt = build_packet(flow, seq=seq)
answered, _ = sr(pkt[pkt.firstlayer().payload.__class__],
timeout=1, verbose=0)
with self._lock:
stats = self._stats.get(flow_id)
if stats:
stats['tx_packets'] += 1
stats['tx_bytes'] += pkt_bytes_len
for sent_pkt, recv_pkt in answered:
rtt_ms = (recv_pkt.time - sent_pkt.sent_time) * 1000
stats['rx_packets'] += 1
stats['rx_bytes'] += len(bytes(recv_pkt))
stats['latency_samples'].append(rtt_ms)
if len(stats['latency_samples']) > 1000:
stats['latency_samples'] = stats['latency_samples'][-1000:]
seq += 1
sleep_time = (1.0 / rate_pps) - (time.time() - start_time - elapsed)
if sleep_time > 0:
stop_event.wait(sleep_time)
except Exception as e:
log.error('Flow %s: ICMP send error: %s', flow_id[:8], e)
finally:
with self._lock:
if flow_id in self._flows:
self._flows[flow_id]['state'] = 'stopped'
def _fetch_responder(self, responder_url: str) -> dict:
"""Fetch raw stats from the responder."""
url = responder_url.rstrip('/') + '/responder/stats'
req = urllib.request.Request(url, method='GET')
req.add_header('Accept', 'application/json')
with urllib.request.urlopen(req, timeout=2) as resp:
return json.loads(resp.read().decode())
def _reset_responder(self, responder_url: str):
"""Reset responder counters."""
url = responder_url.rstrip('/') + '/responder/reset'
req = urllib.request.Request(url, method='POST')
req.add_header('Content-Type', 'application/json')
with urllib.request.urlopen(req, timeout=2) as resp:
resp.read()
def _poll_responder(self, flow_id: str, responder_url: str,
baseline_rx: int = 0, baseline_bytes: int = 0):
"""Poll a responder's /responder/stats endpoint for rx metrics.""" """Poll a responder's /responder/stats endpoint for rx metrics."""
try: try:
data = self._fetch_responder(responder_url) url = responder_url.rstrip('/') + '/responder/stats'
rx_pkts = data.get('rx_packets', 0) - baseline_rx req = urllib.request.Request(url, method='GET')
rx_bytes = data.get('rx_bytes', 0) - baseline_bytes req.add_header('Accept', 'application/json')
with urllib.request.urlopen(req, timeout=2) as resp:
data = json.loads(resp.read().decode())
with self._lock: with self._lock:
stats = self._stats.get(flow_id) stats = self._stats.get(flow_id)
if stats: if stats:
stats['rx_packets'] = max(0, rx_pkts) stats['rx_packets'] = data.get('rx_packets', 0)
stats['rx_bytes'] = max(0, rx_bytes) stats['rx_bytes'] = data.get('rx_bytes', 0)
lat = data.get('latency', {}) lat = data.get('latency', {})
if lat.get('avg_ms') is not None: if lat.get('avg_ms') is not None:
stats['latency_samples'].append(lat['avg_ms']) stats['latency_samples'].append(lat['avg_ms'])

View File

@ -56,14 +56,9 @@ class StatsCollector:
else: else:
tx_pps = rx_pps = tx_mbps = rx_mbps = 0.0 tx_pps = rx_pps = tx_mbps = rx_mbps = 0.0
# Loss calculation: use rate-based when actively sending (avoids # Loss calculation
# poll-lag artifacts), cumulative when flow has stopped
loss_pct = 0.0 loss_pct = 0.0
if prev is not None and tx_pps > 0 and rx_pps > 0: if tx_packets > 0 and rx_packets > 0:
# Rate-based: compare instantaneous tx/rx rates
loss_pct = max(0.0, ((tx_pps - rx_pps) / tx_pps) * 100)
elif tx_packets > 0 and rx_packets > 0 and tx_pps == 0:
# Flow stopped: use cumulative counters (final accurate value)
loss_pct = max(0.0, ((tx_packets - rx_packets) / tx_packets) * 100) loss_pct = max(0.0, ((tx_packets - rx_packets) / tx_packets) * 100)
sample = { sample = {

View File

@ -78,7 +78,6 @@ _tests_lock = threading.Lock()
_responder = None # Responder instance _responder = None # Responder instance
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helper # Helper
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -87,7 +86,6 @@ def _now_iso():
return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
def _flow_response(flow_id: str) -> dict: def _flow_response(flow_id: str) -> dict:
"""Build a serializable flow dict.""" """Build a serializable flow dict."""
with _flows_lock: with _flows_lock:
@ -96,15 +94,8 @@ def _flow_response(flow_id: str) -> dict:
return None return None
result = dict(meta) result = dict(meta)
if _sender: if _sender:
running = _sender.is_running(flow_id) result['is_running'] = _sender.is_running(flow_id)
result['is_running'] = running
result['stats'] = _sender.get_stats(flow_id) result['stats'] = _sender.get_stats(flow_id)
# Sync state: if sender thread finished but meta still says running
if not running and result.get('state') == 'running':
with _flows_lock:
if flow_id in _flows_meta:
_flows_meta[flow_id]['state'] = 'stopped'
result['state'] = 'stopped'
return result return result
@ -157,114 +148,6 @@ def get_mode():
return jsonify({'mode': MODE}) return jsonify({'mode': MODE})
@app.route('/mode', methods=['POST'])
def set_mode():
global MODE, _sender, _stats_collector, _responder
data = request.get_json(force=True)
new_mode = data.get('mode', '').lower()
if new_mode not in ('sender', 'responder'):
return jsonify({'error': 'mode must be "sender" or "responder"'}), 400
if new_mode == MODE:
return jsonify({'mode': MODE, 'changed': False})
listen_iface = os.environ.get('TRAFFIC_GEN_INTERFACE', None)
responder_sub_mode = os.environ.get('TRAFFIC_GEN_RESPONDER_MODE', 'log')
# Tear down current mode
if MODE == 'sender':
# Stop all running flows
if _sender:
for fid in list(_sender.get_all_flows().keys()):
_sender.stop(fid)
with _flows_lock:
_flows_meta.clear()
with _tests_lock:
for t in _tests.values():
if t.state == 'running':
t.stop()
_tests.clear()
elif MODE == 'responder':
if _responder:
_responder.stop()
_responder = None
# Start new mode
if new_mode == 'sender':
from engine.sender import FlowSender
from engine.stats import StatsCollector
_sender = FlowSender()
_stats_collector = StatsCollector()
_responder = None
elif new_mode == 'responder':
from engine.responder import Responder
_sender = None
_stats_collector = None
_responder = Responder(mode=responder_sub_mode)
_responder.start(interface=listen_iface)
MODE = new_mode
log.info('Mode switched to %s', MODE)
return jsonify({'mode': MODE, 'changed': True})
# ---------------------------------------------------------------------------
# Quick Ping (works in any mode)
# ---------------------------------------------------------------------------
@app.route('/ping', methods=['POST'])
def quick_ping():
"""Send ICMP pings to a target and return results."""
import subprocess
data = request.get_json(force=True)
target = data.get('target', '').strip()
count = min(int(data.get('count', 5)), 20)
if not target:
return jsonify({'error': 'target is required'}), 400
try:
result = subprocess.run(
['ping', '-c', str(count), '-W', '2', target],
capture_output=True, text=True, timeout=count * 3 + 5
)
output = result.stdout + result.stderr
# Parse ping output
lines = output.strip().split('\n')
replies = []
for line in lines:
if 'time=' in line:
try:
time_ms = float(line.split('time=')[1].split(' ')[0])
replies.append(time_ms)
except (IndexError, ValueError):
pass
stats = {}
if replies:
stats = {
'min_ms': round(min(replies), 2),
'avg_ms': round(sum(replies) / len(replies), 2),
'max_ms': round(max(replies), 2),
}
return jsonify({
'target': target,
'sent': count,
'received': len(replies),
'loss_pct': round(((count - len(replies)) / count) * 100, 1),
'replies': replies,
'stats': stats,
'reachable': len(replies) > 0,
})
except subprocess.TimeoutExpired:
return jsonify({'target': target, 'error': 'Ping timed out', 'reachable': False})
except Exception as e:
return jsonify({'target': target, 'error': str(e), 'reachable': False})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Sender-mode: Flow endpoints # Sender-mode: Flow endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -310,7 +193,7 @@ def create_flow():
'dscp': int(data.get('dscp', 0)), 'dscp': int(data.get('dscp', 0)),
'vlan_id': data.get('vlan_id'), 'vlan_id': data.get('vlan_id'),
'state': 'idle', 'state': 'idle',
'responder_url': data.get('responder_url') or os.environ.get('RESPONDER_URL') or None, 'responder_url': data.get('responder_url'),
'created_at': _now_iso(), 'created_at': _now_iso(),
} }
@ -452,51 +335,34 @@ def create_test():
from engine.rfc2544 import create_test as _create_test from engine.rfc2544 import create_test as _create_test
data = request.get_json(force=True) data = request.get_json(force=True)
flow_id = data.get('flow_id')
test_type = data.get('type') test_type = data.get('type')
if not test_type: if not flow_id or not test_type:
return jsonify({'error': 'type is required'}), 400 return jsonify({'error': 'flow_id and type are required'}), 400
# Accept either flow_config directly or flow_id to look up with _flows_lock:
flow_config = data.get('flow_config') flow_meta = _flows_meta.get(flow_id)
flow_id = data.get('flow_id') if flow_meta is None:
return jsonify({'error': 'Flow not found'}), 404
if flow_config:
# Direct flow config provided — no flow_id needed
if not flow_config.get('dst_ip'):
return jsonify({'error': 'flow_config.dst_ip is required'}), 400
flow_config.setdefault('src_ip', 'auto')
flow_config.setdefault('protocol', 'udp')
flow_config.setdefault('src_port', 50000)
flow_config.setdefault('dst_port', 5001)
elif flow_id:
with _flows_lock:
flow_meta = _flows_meta.get(flow_id)
if flow_meta is None:
return jsonify({'error': 'Flow not found'}), 404
flow_config = dict(flow_meta)
else:
return jsonify({'error': 'flow_config or flow_id is required'}), 400
test_id = str(uuid.uuid4()) test_id = str(uuid.uuid4())
kwargs = { kwargs = {
'frame_sizes': data.get('frame_sizes', [64, 512, 1518]), 'frame_sizes': data.get('frame_sizes', [64, 512, 1518]),
'trial_duration': float(data.get('trial_duration', 60)), 'trial_duration': float(data.get('trial_duration', 60)),
'max_rate_pps': int(data.get('max_rate_pps', flow_config.get('rate_pps', 10000))), 'max_rate_pps': int(data.get('max_rate_pps', flow_meta.get('rate_pps', 10000))),
'acceptable_loss_pct': float(data.get('acceptable_loss_pct', 0.0)), 'acceptable_loss_pct': float(data.get('acceptable_loss_pct', 0.0)),
'responder_url': data.get('responder_url') or os.environ.get('RESPONDER_URL') or None,
} }
try: try:
test = _create_test(test_id, test_type, flow_config, **kwargs) test = _create_test(test_id, test_type, dict(flow_meta), **kwargs)
except ValueError as e: except ValueError as e:
return jsonify({'error': str(e)}), 400 return jsonify({'error': str(e)}), 400
with _tests_lock: with _tests_lock:
_tests[test_id] = test _tests[test_id] = test
log.info('Created test %s (type=%s, dst=%s)', test_id[:8], test_type, log.info('Created test %s (type=%s, flow=%s)', test_id[:8], test_type, flow_id[:8])
flow_config.get('dst_ip', '?'))
return jsonify(test.get_info()), 201 return jsonify(test.get_info()), 201
@ -617,7 +483,7 @@ def load_preset(name):
'dscp': int(flow_data.get('dscp', 0)), 'dscp': int(flow_data.get('dscp', 0)),
'vlan_id': flow_data.get('vlan_id'), 'vlan_id': flow_data.get('vlan_id'),
'state': 'idle', 'state': 'idle',
'responder_url': flow_data.get('responder_url') or os.environ.get('RESPONDER_URL') or None, 'responder_url': flow_data.get('responder_url'),
'created_at': _now_iso(), 'created_at': _now_iso(),
} }