Add Phase 2: Vue 3 control panel, 6 learning dashboards, new BGP scenarios

- exabgp-ui/: Vue 3 + Vite SPA served by NGINX on :5001; proxies /api/ to
  ExaBGP Flask on :5050; includes StatusBar, ScenarioPanel, RouteTable,
  AnnounceForm, PeerStatus, ChurnControl components
- docker-compose.yml: add obmp-exabgp-ui service (host network, port 5001)
- exabgp/scenarios/__init__.py: add convergence_test, route_leak,
  hijack_simulation scenarios for structured BGP learning exercises
- exabgp/inject.py: add 'peers' and 'monitor' subcommands; live-refresh
  terminal status view with ANSI cursor repositioning
- obmp-grafana/dashboards/Learning/: 6 new OBMP-Learning dashboards
  (update rate, peer health, AS path, RPKI, churn, attributes)
- obmp-grafana/provisioning/dashboards/openbmp-dashboards.yml: add
  OpenBMP-Learning folder provider pointing to dashboards/Learning/
- DOCS.md: document Web UI, 3 new scenarios, 6 learning dashboards;
  fix section numbering (10-14) and architecture diagram (23 dashboards)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sam 2026-03-05 15:37:16 -07:00
parent 233dadbb41
commit 6621942032
25 changed files with 3487 additions and 15 deletions

94
DOCS.md
View File

@ -9,12 +9,14 @@
5. [IOS-XR Router Configuration](#5-ios-xr-router-configuration)
6. [Starting and Stopping](#6-starting-and-stopping)
7. [Route Injection User Guide](#7-route-injection-user-guide)
8. [Grafana Dashboards](#8-grafana-dashboards)
9. [Sanity Checks](#9-sanity-checks)
10. [Relevant Commands Reference](#10-relevant-commands-reference)
11. [Troubleshooting](#11-troubleshooting)
12. [Data Retention](#12-data-retention)
13. [Environment Variables Reference](#13-environment-variables-reference)
8. [ExaBGP Control Panel (Web UI)](#8-exabgp-control-panel-web-ui)
9. [Grafana Dashboards](#9-grafana-dashboards)
10. [Sanity Checks](#10-sanity-checks)
11. [Relevant Commands Reference](#11-relevant-commands-reference)
12. [Troubleshooting](#12-troubleshooting)
13. [Data Retention](#13-data-retention)
14. [Environment Variables Reference](#14-environment-variables-reference)
---
@ -26,8 +28,9 @@ This is a **BGP Monitoring Platform (BMP) lab stack** deployed via Docker Compos
- Receives BMP (BGP Monitoring Protocol, RFC 7854) telemetry from routers on TCP port 5000
- Streams BMP data through Kafka into a TimescaleDB/PostgreSQL database
- Provides 17 Grafana dashboards for real-time and historical BGP analysis
- Provides **23 Grafana dashboards** (17 operational + 6 learning-focused) for real-time and historical BGP analysis
- Includes an **ExaBGP route injector** that peers with the two CORE routers and injects synthetic BGP routes, enabling testing of BGP policy, route propagation, and Grafana dashboards without needing internet connectivity
- Provides a **Vue 3 web UI** at `:5001` for point-and-click scenario management, live route tables, and peer monitoring
**The lab network:**
@ -61,7 +64,7 @@ IOS-XR Routers (9x, AS 65020)
PostgreSQL 14 + TimescaleDB
|
+---------> obmp-grafana (grafana/grafana:9.1.7) :3000
| 17 dashboards, PostgreSQL datasource
| 23 dashboards, PostgreSQL datasource
+---------> obmp-whois (openbmp/whois:2.2.0) :4300
WHOIS query server backed by the DB
@ -84,6 +87,7 @@ ExaBGP (obmp-exabgp, built locally)
| obmp-grafana | grafana/grafana:9.1.7 | 3000 | Visualization |
| obmp-whois | openbmp/whois:2.2.0 | 4300 | WHOIS query server |
| obmp-exabgp | local build | 5050 (host net) | BGP route injector |
| obmp-exabgp-ui | local build | 5001 (host net) | Vue 3 web control panel |
---
@ -305,6 +309,9 @@ python3 inject.py scenarios
| `anycast` | 3 | Same prefixes with varying AS paths and MEDs (best-path testing) |
| `full_table` | 500+ | Large partial internet table with synthetic /24s |
| `lab_prefixes` | 8 | Enterprise/SP-style routes with communities and local-pref |
| `convergence_test` | 10 | Prefixes for timing BGP convergence — announce then check ip_rib_log timestamps |
| `route_leak` | 10 | Real prefixes re-announced with short AS paths — simulates a route leak (community 65100:999) |
| `hijack_simulation` | 10 | Prefixes claimed directly by AS 65100 — simulates a prefix hijack (community 65100:hijack) |
### 7.4 Load a scenario
@ -400,7 +407,51 @@ docker compose -p obmp restart exabgp
---
## 8. Grafana Dashboards
## 8. ExaBGP Control Panel (Web UI)
Access: `http://10.40.40.202:5001`
A Vue 3 single-page app served by NGINX that proxies `/api/` to the ExaBGP Flask API on port 5050. No login required.
### Layout
```
┌─────────────────────────────────────────────────────────────┐
│ OpenBMP Route Injector [API OK] [77 routes] [2/2 UP] │
├──────────────────────┬──────────────────────────────────────┤
│ SCENARIOS │ [Routes] [Inject] [Peers] tabs │
│ │ │
│ [internet_sample] │ Routes tab: searchable/paginated │
│ [LOAD] [UNLOAD] │ table with per-row Withdraw button │
│ │ │
│ [churn] │ Inject tab: manual prefix form │
│ [LOAD] [START CHURN]│ (prefix, AS path, communities, MED) │
│ │ │
│ [blackhole] ... │ Peers tab: per-peer UP/DOWN cards │
├──────────────────────┴──────────────────────────────────────┤
│ Refreshing every 5s │
└─────────────────────────────────────────────────────────────┘
```
### Features
- **Live status bar** — API health, active route count, peer UP/DOWN badges; auto-refreshes every 5 seconds
- **Scenario panel** — Load/Unload buttons for all 9 scenarios with loading states and feedback
- **Churn control** — Start/stop churn cycles with configurable count and interval sliders directly in the browser
- **Route table** — Searchable, paginated (20/page) table of active routes; per-row Withdraw button; Withdraw All
- **Manual inject form** — Announce any prefix with custom AS path, communities, MED, local-pref
- **Peer cards** — Per-peer state display with UP (green) / DOWN (red pulsing) indicators
### Rebuild after code changes
```bash
docker compose -p obmp build exabgp-ui
docker compose -p obmp up -d exabgp-ui
```
---
## 9. Grafana Dashboards
Access: `http://10.40.40.202:3000`
Default credentials: `admin` / `openbmp` (anonymous access also enabled)
@ -429,9 +480,24 @@ Default credentials: `admin` / `openbmp` (anonymous access also enabled)
> History dashboards require `ip_rib_log` and `stats_chg_*` table data. Run `inject.py churn` to populate these.
### OBMP-Learning Dashboards (folder: `OBMP-Learning`)
Six learning-focused dashboards in a separate folder, designed to teach BGP concepts using live lab data.
| Dashboard | UID | What it teaches |
|-----------|-----|-----------------|
| BGP Update Rate & Churn | `obmp-learn-01` | Network stability — advertisements vs withdrawals over time from `ip_rib_log`; per-peer update counts |
| Peer Session Health & Flap Analysis | `obmp-learn-02` | BGP session stability — state timeline, flap count, uptime %, last reset reason |
| AS Path Analysis | `obmp-learn-03` | Internet topology — path length distribution, longest paths, top origin ASNs, transit frequency |
| RPKI Validation Status | `obmp-learn-04` | BGP security — Valid / Invalid / NotFound breakdown; invalid routes (potential hijacks) table |
| Route Churn & Stability Score | `obmp-learn-05` | Prefix stability — tiered churn score (Very Stable / Stable / Moderate / Unstable) per prefix |
| BGP Attribute Explorer | `obmp-learn-06` | BGP path attributes — community list distribution, MED values, local-pref spread per peer |
> **RPKI note:** The `rpki_validator` table is populated by a cron job in `psql-app` every 2 hours. Dashboard `obmp-learn-04` will show zero counts until the cron runs — check `ENABLE_RPKI=1` in `docker-compose.yml`.
---
## 9. Sanity Checks
## 10. Sanity Checks
### 9.1 All containers running
@ -517,7 +583,7 @@ Should show periodic cron job outputs (RPKI sync, IRR sync, global_ip_rib update
---
## 10. Relevant Commands Reference
## 11. Relevant Commands Reference
### Docker Compose
@ -622,7 +688,7 @@ show route 8.8.8.0/24
---
## 11. Troubleshooting
## 12. Troubleshooting
### ExaBGP container keeps restarting
@ -692,7 +758,7 @@ docker compose -p obmp restart psql-app
---
## 12. Data Retention
## 13. Data Retention
Configured in `docker-compose.yml` via `POSTGRES_DROP_*` environment variables:
@ -716,7 +782,7 @@ Adjust in `docker-compose.yml` under the `psql-app` service environment block.
---
## 13. Environment Variables Reference
## 14. Environment Variables Reference
### ExaBGP container

View File

@ -221,6 +221,16 @@ services:
- ./exabgp/scenarios:/exabgp/scenarios
# No ports: block needed — network_mode: host exposes directly
exabgp-ui:
restart: unless-stopped
container_name: obmp-exabgp-ui
build:
context: ./exabgp-ui
dockerfile: Dockerfile
# Host networking so NGINX can proxy /api to ExaBGP Flask on localhost:5050
network_mode: host
# Serves on port 5001 (host network, defined in nginx.conf)
whois:
restart: unless-stopped
container_name: obmp-whois

12
exabgp-ui/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 5001
CMD ["nginx", "-g", "daemon off;"]

16
exabgp-ui/index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenBMP Route Injector</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f1117; color: #e2e8f0; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

15
exabgp-ui/nginx.conf Normal file
View File

@ -0,0 +1,15 @@
server {
listen 5001;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://localhost:5050/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
try_files $uri $uri/ /index.html;
}
}

17
exabgp-ui/package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "exabgp-ui",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.0",
"vite": "^4.4.0"
}
}

301
exabgp-ui/src/App.vue Normal file
View File

@ -0,0 +1,301 @@
<template>
<div class="app-layout">
<!-- HEADER -->
<header class="app-header">
<div class="header-title">
<span class="logo-icon">&#9711;</span>
<h1>OpenBMP Route Injector</h1>
</div>
<StatusBar :health="health" :api-error="apiError" />
</header>
<!-- ERROR BANNER -->
<div v-if="apiError" class="error-banner">
<span class="error-icon">&#9888;</span>
API unreachable: {{ apiError }} &mdash; retrying every 5s
</div>
<!-- MAIN CONTENT -->
<div class="main-content">
<!-- LEFT COLUMN: Scenarios -->
<aside class="left-col">
<ScenarioPanel @routes-changed="fetchRoutes" />
</aside>
<!-- RIGHT COLUMN: Tabs -->
<main class="right-col">
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab-btn"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</div>
<div class="tab-content">
<RouteTable v-if="activeTab === 'routes'" :routes="routes" @refresh="fetchRoutes" />
<AnnounceForm v-else-if="activeTab === 'inject'" @routes-changed="fetchRoutes" />
<PeerStatus v-else-if="activeTab === 'peers'" :peers="peers" />
<ChurnControl v-else-if="activeTab === 'churn'" />
</div>
</main>
</div>
<!-- FOOTER -->
<footer class="app-footer">
<span>Refreshing every 5s (health) / 10s (routes)</span>
<span class="footer-sep">|</span>
<a href="http://localhost:3000" target="_blank" class="footer-link">Grafana: :3000</a>
</footer>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { api } from './api.js'
import StatusBar from './components/StatusBar.vue'
import ScenarioPanel from './components/ScenarioPanel.vue'
import RouteTable from './components/RouteTable.vue'
import AnnounceForm from './components/AnnounceForm.vue'
import PeerStatus from './components/PeerStatus.vue'
import ChurnControl from './components/ChurnControl.vue'
const health = ref(null)
const routes = ref([])
const peers = ref({})
const apiError = ref(null)
const activeTab = ref('routes')
const tabs = [
{ id: 'routes', label: 'Routes' },
{ id: 'inject', label: 'Inject' },
{ id: 'peers', label: 'Peers' },
{ id: 'churn', label: 'Churn' },
]
async function fetchHealth() {
try {
const data = await api.health()
health.value = data
peers.value = data.peers || {}
apiError.value = null
} catch (e) {
apiError.value = e.message
health.value = null
}
}
async function fetchRoutes() {
try {
const data = await api.routes()
routes.value = data.routes || []
} catch (e) {
// silently fail; error shown via health banner
}
}
let healthTimer = null
let routesTimer = null
onMounted(() => {
fetchHealth()
fetchRoutes()
healthTimer = setInterval(fetchHealth, 5000)
routesTimer = setInterval(fetchRoutes, 10000)
})
onUnmounted(() => {
clearInterval(healthTimer)
clearInterval(routesTimer)
})
</script>
<style>
:root {
--bg: #0f1117;
--card-bg: #1a1f2e;
--border: #2d3748;
--accent: #4f9cf9;
--success: #48bb78;
--danger: #fc8181;
--warning: #f6ad55;
--text: #e2e8f0;
--muted: #718096;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
}
button {
cursor: pointer;
font-family: inherit;
font-size: 13px;
border: none;
border-radius: var(--radius);
transition: opacity 0.15s, background 0.15s;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input, select {
font-family: inherit;
font-size: 13px;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 10px;
outline: none;
}
input:focus {
border-color: var(--accent);
}
</style>
<style scoped>
.app-layout {
display: grid;
grid-template-rows: auto auto 1fr auto;
min-height: 100vh;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: var(--card-bg);
border-bottom: 1px solid var(--border);
gap: 16px;
flex-wrap: wrap;
}
.header-title {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
font-size: 22px;
color: var(--accent);
}
.app-header h1 {
font-size: 18px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.02em;
}
.error-banner {
background: rgba(252, 129, 129, 0.12);
border-bottom: 1px solid var(--danger);
color: var(--danger);
padding: 8px 20px;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.error-icon {
font-size: 16px;
}
.main-content {
display: grid;
grid-template-columns: 380px 1fr;
overflow: hidden;
height: calc(100vh - 110px);
}
.left-col {
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 16px;
}
.right-col {
display: flex;
flex-direction: column;
overflow: hidden;
}
.tabs {
display: flex;
gap: 2px;
padding: 12px 16px 0;
background: var(--card-bg);
border-bottom: 1px solid var(--border);
}
.tab-btn {
background: transparent;
color: var(--muted);
padding: 8px 18px;
border-radius: var(--radius) var(--radius) 0 0;
border: 1px solid transparent;
border-bottom: none;
font-weight: 500;
}
.tab-btn:hover {
color: var(--text);
background: rgba(79, 156, 249, 0.08);
}
.tab-btn.active {
color: var(--accent);
background: var(--bg);
border-color: var(--border);
border-bottom: 1px solid var(--bg);
margin-bottom: -1px;
}
.tab-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.app-footer {
padding: 8px 20px;
background: var(--card-bg);
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.footer-sep {
color: var(--border);
}
.footer-link {
color: var(--accent);
text-decoration: none;
}
.footer-link:hover {
text-decoration: underline;
}
</style>

21
exabgp-ui/src/api.js Normal file
View File

@ -0,0 +1,21 @@
const BASE = '/api'
async function req(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } }
if (body) opts.body = JSON.stringify(body)
const r = await fetch(BASE + path, opts)
if (!r.ok) throw new Error(`${method} ${path}${r.status}`)
return r.json()
}
export const api = {
health: () => req('GET', '/healthz'),
peers: () => req('GET', '/peers'),
routes: () => req('GET', '/routes'),
scenarios: () => req('GET', '/scenarios'),
loadScenario: name => req('POST', `/scenario/${name}`),
unloadScenario: name => req('DELETE', `/scenario/${name}`),
announce: payload => req('POST', '/announce', payload),
withdraw: prefixes => req('POST', '/withdraw', { prefixes }),
withdrawAll: () => req('POST', '/withdraw/all'),
}

View File

@ -0,0 +1,383 @@
<template>
<div class="announce-form">
<h2 class="section-title">Manual Route Injection</h2>
<form @submit.prevent="handleAnnounce" class="form-grid">
<div class="form-group">
<label>Prefix <span class="required">*</span></label>
<input
v-model="prefix"
type="text"
placeholder="10.0.0.0/8"
required
class="mono-input"
/>
</div>
<div class="form-group">
<label>Next Hop</label>
<input
v-model="nextHop"
type="text"
placeholder="192.168.1.1"
class="mono-input"
/>
</div>
<div class="form-group">
<label>AS Path</label>
<input
v-model="asPathRaw"
type="text"
placeholder="65100 3356 15169"
class="mono-input"
/>
<span class="hint">Space-separated ASNs</span>
</div>
<div class="form-group">
<label>Communities</label>
<input
v-model="communitiesRaw"
type="text"
placeholder="65100:100 65100:200"
class="mono-input"
/>
<span class="hint">Space-separated, e.g. ASN:value</span>
</div>
<div class="form-group form-group-half">
<label>MED</label>
<input
v-model.number="med"
type="number"
placeholder="100"
min="0"
class="mono-input"
/>
</div>
<div class="form-group form-group-half">
<label>Local Pref</label>
<input
v-model.number="localPref"
type="number"
placeholder="100"
min="0"
class="mono-input"
/>
</div>
<div class="form-actions">
<button type="submit" class="btn-announce" :disabled="pending || !prefix.trim()">
<span v-if="pending === 'announce'" class="spinner-sm"></span>
<span v-else>&#9654;</span>
Announce
</button>
<button
type="button"
class="btn-withdraw-prefix"
:disabled="pending || !prefix.trim()"
@click="handleWithdrawPrefix"
>
<span v-if="pending === 'withdraw'" class="spinner-sm"></span>
Withdraw Prefix
</button>
<button
type="button"
class="btn-withdraw-all"
:disabled="pending"
@click="handleWithdrawAll"
>
<span v-if="pending === 'withdrawAll'" class="spinner-sm"></span>
Withdraw All
</button>
</div>
</form>
<div v-if="feedback" class="feedback" :class="feedback.type">
{{ feedback.msg }}
</div>
<!-- Preview -->
<div class="preview-section" v-if="prefix.trim()">
<div class="preview-title">Preview</div>
<pre class="preview-box">{{ buildPayloadPreview() }}</pre>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { api } from '../api.js'
const emit = defineEmits(['routes-changed'])
const prefix = ref('')
const nextHop = ref('')
const asPathRaw = ref('')
const communitiesRaw = ref('')
const med = ref(null)
const localPref = ref(null)
const pending = ref(null)
const feedback = ref(null)
function parseAsPath(raw) {
if (!raw.trim()) return []
return raw.trim().split(/\s+/).map(n => parseInt(n, 10)).filter(n => !isNaN(n))
}
function parseCommunities(raw) {
if (!raw.trim()) return []
return raw.trim().split(/\s+/).filter(Boolean)
}
function buildPayload() {
const payload = {
prefixes: [prefix.value.trim()],
}
if (nextHop.value.trim()) payload.next_hop = nextHop.value.trim()
const aspath = parseAsPath(asPathRaw.value)
if (aspath.length) payload.as_path = aspath
const comms = parseCommunities(communitiesRaw.value)
if (comms.length) payload.communities = comms
if (med.value !== null && med.value !== '') payload.med = med.value
if (localPref.value !== null && localPref.value !== '') payload.local_pref = localPref.value
return payload
}
function buildPayloadPreview() {
return JSON.stringify(buildPayload(), null, 2)
}
function showFeedback(type, msg) {
feedback.value = { type, msg }
setTimeout(() => { feedback.value = null }, 5000)
}
async function handleAnnounce() {
if (!prefix.value.trim()) return
pending.value = 'announce'
try {
const payload = buildPayload()
await api.announce(payload)
showFeedback('success', `Announced ${prefix.value.trim()} successfully.`)
emit('routes-changed')
} catch (e) {
showFeedback('error', `Announce failed: ${e.message}`)
} finally {
pending.value = null
}
}
async function handleWithdrawPrefix() {
if (!prefix.value.trim()) return
pending.value = 'withdraw'
try {
await api.withdraw([prefix.value.trim()])
showFeedback('warn', `Withdrawn ${prefix.value.trim()}.`)
emit('routes-changed')
} catch (e) {
showFeedback('error', `Withdraw failed: ${e.message}`)
} finally {
pending.value = null
}
}
async function handleWithdrawAll() {
if (!confirm('Withdraw ALL active routes? This cannot be undone.')) return
pending.value = 'withdrawAll'
try {
await api.withdrawAll()
showFeedback('warn', 'All routes withdrawn.')
emit('routes-changed')
} catch (e) {
showFeedback('error', `Withdraw all failed: ${e.message}`)
} finally {
pending.value = null
}
}
</script>
<style scoped>
.announce-form {
max-width: 700px;
display: flex;
flex-direction: column;
gap: 18px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.form-grid {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
grid-column: span 2;
}
.form-group-half {
grid-column: span 1;
}
label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.required {
color: var(--danger);
}
.mono-input {
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
}
.hint {
font-size: 11px;
color: #4a5568;
}
.form-actions {
grid-column: span 2;
display: flex;
gap: 10px;
flex-wrap: wrap;
padding-top: 4px;
border-top: 1px solid var(--border);
}
.btn-announce {
padding: 8px 20px;
background: rgba(79, 156, 249, 0.15);
color: var(--accent);
border: 1px solid rgba(79, 156, 249, 0.3);
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.btn-announce:hover:not(:disabled) {
background: rgba(79, 156, 249, 0.25);
}
.btn-withdraw-prefix {
padding: 8px 20px;
background: rgba(246, 173, 85, 0.12);
color: #f6ad55;
border: 1px solid rgba(246, 173, 85, 0.25);
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.btn-withdraw-prefix:hover:not(:disabled) {
background: rgba(246, 173, 85, 0.22);
}
.btn-withdraw-all {
padding: 8px 20px;
background: rgba(252, 129, 129, 0.12);
color: #fc8181;
border: 1px solid rgba(252, 129, 129, 0.25);
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.btn-withdraw-all:hover:not(:disabled) {
background: rgba(252, 129, 129, 0.22);
}
.feedback {
padding: 10px 14px;
border-radius: var(--radius);
font-size: 13px;
}
.feedback.success {
background: rgba(72, 187, 120, 0.12);
color: #48bb78;
border: 1px solid rgba(72, 187, 120, 0.25);
}
.feedback.warn {
background: rgba(246, 173, 85, 0.12);
color: #f6ad55;
border: 1px solid rgba(246, 173, 85, 0.25);
}
.feedback.error {
background: rgba(252, 129, 129, 0.12);
color: #fc8181;
border: 1px solid rgba(252, 129, 129, 0.25);
}
.preview-section {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.preview-title {
font-size: 11px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 8px 14px;
border-bottom: 1px solid var(--border);
background: rgba(0,0,0,0.15);
}
.preview-box {
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
color: #a0c4ff;
padding: 14px;
margin: 0;
white-space: pre;
overflow-x: auto;
}
.spinner-sm {
width: 11px;
height: 11px;
border: 2px solid rgba(255,255,255,0.2);
border-top-color: currentColor;
border-radius: 50%;
display: inline-block;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>

View File

@ -0,0 +1,481 @@
<template>
<div class="churn-control">
<h2 class="section-title">Churn Control</h2>
<p class="section-desc">
Repeatedly load and unload a scenario to simulate BGP route churn.
Runs entirely in the browser via timed API calls.
</p>
<div class="config-card">
<!-- Scenario selector -->
<div class="form-group">
<label>Scenario Name</label>
<input
v-model="scenarioName"
type="text"
placeholder="churn"
:disabled="running"
class="mono-input"
/>
</div>
<!-- Cycle count -->
<div class="form-group">
<label>Cycles: <span class="slider-val">{{ cycleCount === 0 ? '&#8734; (infinite)' : cycleCount }}</span></label>
<input
v-model.number="cycleCount"
type="range"
min="0"
max="20"
step="1"
:disabled="running"
class="slider"
/>
<div class="slider-labels">
<span>0 (&#8734;)</span>
<span>5</span>
<span>10</span>
<span>15</span>
<span>20</span>
</div>
</div>
<!-- Announce duration -->
<div class="form-group">
<label>Announce Duration: <span class="slider-val">{{ announceSecs }}s</span></label>
<input
v-model.number="announceSecs"
type="range"
min="5"
max="120"
step="5"
:disabled="running"
class="slider"
/>
<div class="slider-labels">
<span>5s</span>
<span>30s</span>
<span>60s</span>
<span>90s</span>
<span>120s</span>
</div>
</div>
<!-- Withdraw duration -->
<div class="form-group">
<label>Withdraw Duration: <span class="slider-val">{{ withdrawSecs }}s</span></label>
<input
v-model.number="withdrawSecs"
type="range"
min="5"
max="120"
step="5"
:disabled="running"
class="slider"
/>
<div class="slider-labels">
<span>5s</span>
<span>30s</span>
<span>60s</span>
<span>90s</span>
<span>120s</span>
</div>
</div>
<!-- Action buttons -->
<div class="action-row">
<button v-if="!running" class="btn-start" @click="startChurn" :disabled="!scenarioName.trim()">
<span>&#9654;</span> Start Churn
</button>
<button v-else class="btn-stop" @click="stopChurn">
<span>&#9632;</span> Stop
</button>
</div>
</div>
<!-- Status display -->
<div v-if="running || statusMsg" class="status-card">
<div class="status-header">
<span class="status-dot" :class="running ? 'dot-active' : 'dot-idle'"></span>
<span class="status-text">{{ statusMsg || 'Idle' }}</span>
</div>
<!-- Progress bar (only when cycling with fixed count) -->
<div v-if="running && cycleCount > 0" class="progress-section">
<div class="progress-labels">
<span>Cycle {{ currentCycle }} / {{ cycleCount }}</span>
<span>{{ progressPct }}%</span>
</div>
<div class="progress-track">
<div class="progress-fill" :style="{ width: progressPct + '%' }"></div>
</div>
</div>
<!-- Phase progress bar -->
<div v-if="running && phaseTotal > 0" class="progress-section">
<div class="progress-labels">
<span>{{ phaseLabel }} {{ phaseElapsed }}s / {{ phaseTotal }}s</span>
<span>{{ phasePct }}%</span>
</div>
<div class="progress-track">
<div class="progress-fill phase-fill" :style="{ width: phasePct + '%' }"></div>
</div>
</div>
<!-- Error -->
<div v-if="churnError" class="churn-error">{{ churnError }}</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import { api } from '../api.js'
const scenarioName = ref('churn')
const cycleCount = ref(5)
const announceSecs = ref(30)
const withdrawSecs = ref(15)
const running = ref(false)
const currentCycle = ref(0)
const statusMsg = ref('')
const churnError = ref(null)
const phaseLabel = ref('')
const phaseElapsed = ref(0)
const phaseTotal = ref(0)
let stopFlag = false
let phaseTimer = null
const progressPct = computed(() => {
if (!cycleCount.value || currentCycle.value === 0) return 0
return Math.round(((currentCycle.value - 1) / cycleCount.value) * 100)
})
const phasePct = computed(() => {
if (!phaseTotal.value) return 0
return Math.min(100, Math.round((phaseElapsed.value / phaseTotal.value) * 100))
})
function sleep(ms) {
return new Promise(resolve => {
const start = Date.now()
const tick = () => {
if (stopFlag) return resolve()
const elapsed = (Date.now() - start) / 1000
phaseElapsed.value = Math.round(elapsed)
if (Date.now() - start >= ms) {
resolve()
} else {
phaseTimer = setTimeout(tick, 500)
}
}
tick()
})
}
async function startChurn() {
if (!scenarioName.value.trim()) return
running.value = true
stopFlag = false
currentCycle.value = 0
churnError.value = null
statusMsg.value = 'Starting...'
const maxCycles = cycleCount.value === 0 ? Infinity : cycleCount.value
try {
for (let i = 1; i <= maxCycles; i++) {
if (stopFlag) break
currentCycle.value = i
statusMsg.value = `Cycle ${cycleCount.value > 0 ? i + '/' + cycleCount.value : i} — Announcing routes...`
phaseLabel.value = 'Announcing'
phaseTotal.value = announceSecs.value
phaseElapsed.value = 0
try {
await api.loadScenario(scenarioName.value.trim())
} catch (e) {
churnError.value = `Load failed: ${e.message}`
}
await sleep(announceSecs.value * 1000)
if (stopFlag) break
statusMsg.value = `Cycle ${cycleCount.value > 0 ? i + '/' + cycleCount.value : i} — Withdrawing routes...`
phaseLabel.value = 'Withdrawing'
phaseTotal.value = withdrawSecs.value
phaseElapsed.value = 0
try {
await api.unloadScenario(scenarioName.value.trim())
} catch (e) {
churnError.value = `Unload failed: ${e.message}`
}
await sleep(withdrawSecs.value * 1000)
}
} catch (e) {
churnError.value = `Churn error: ${e.message}`
} finally {
running.value = false
phaseTotal.value = 0
phaseElapsed.value = 0
if (!stopFlag) {
statusMsg.value = `Churn complete — ${currentCycle.value} cycle(s) finished`
} else {
statusMsg.value = `Stopped after ${currentCycle.value} cycle(s)`
}
}
}
function stopChurn() {
stopFlag = true
if (phaseTimer) clearTimeout(phaseTimer)
statusMsg.value = 'Stopping...'
}
onUnmounted(() => {
stopFlag = true
if (phaseTimer) clearTimeout(phaseTimer)
})
</script>
<style scoped>
.churn-control {
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;
}
.slider-val {
color: var(--accent);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
text-transform: none;
letter-spacing: 0;
}
.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: 300px;
}
.mono-input:focus {
border-color: var(--accent);
}
.slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 5px;
border-radius: 3px;
background: var(--border);
outline: none;
cursor: pointer;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: none;
}
.slider:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.slider-labels {
display: flex;
justify-content: space-between;
font-size: 10px;
color: #4a5568;
padding: 0 2px;
}
.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);
}
.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;
}
.phase-fill {
background: #48bb78;
}
.churn-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

@ -0,0 +1,214 @@
<template>
<div class="peer-status">
<h2 class="section-title">BGP Peers</h2>
<div v-if="!peerList.length" class="empty-state">
No peer data yet.
</div>
<div v-else class="peer-grid">
<div
v-for="peer in peerList"
:key="peer.ip"
class="peer-card"
:class="{ 'peer-down': peer.state !== 'up' }"
>
<div class="peer-header">
<div class="peer-ip">{{ peer.ip }}</div>
<div class="peer-badge" :class="peer.state === 'up' ? 'badge-up' : 'badge-down'">
<span class="dot" :class="peer.state === 'up' ? 'dot-up' : 'dot-down'"></span>
{{ peer.state.toUpperCase() }}
</div>
</div>
<div class="peer-meta">
<div class="meta-row">
<span class="meta-label">Last Updated</span>
<span class="meta-value">{{ formatTime(peer.updated) }}</span>
</div>
</div>
<div v-if="peer.state !== 'up'" class="down-warning">
Session DOWN routes may be stale
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
peers: { type: Object, default: () => ({}) },
})
const peerList = computed(() => {
if (!props.peers) return []
return Object.entries(props.peers).map(([ip, info]) => ({
ip,
state: (info.state || 'unknown').toLowerCase(),
updated: info.updated || null,
})).sort((a, b) => {
// UP peers first
if (a.state === 'up' && b.state !== 'up') return -1
if (b.state === 'up' && a.state !== 'up') return 1
return a.ip.localeCompare(b.ip)
})
})
function formatTime(ts) {
if (!ts) return 'Unknown'
try {
const d = new Date(ts)
return d.toLocaleString()
} catch {
return ts
}
}
</script>
<style scoped>
.peer-status {
display: flex;
flex-direction: column;
gap: 16px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.empty-state {
color: var(--muted);
text-align: center;
padding: 40px 0;
font-size: 14px;
}
.peer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.peer-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 12px;
transition: border-color 0.2s;
}
.peer-card:hover {
border-color: rgba(79, 156, 249, 0.3);
}
.peer-down {
border-color: rgba(252, 129, 129, 0.3);
background: rgba(252, 129, 129, 0.04);
}
.peer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.peer-ip {
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 16px;
font-weight: 700;
color: var(--text);
letter-spacing: 0.02em;
}
.peer-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 16px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
border: 1px solid;
}
.badge-up {
background: rgba(72, 187, 120, 0.12);
border-color: rgba(72, 187, 120, 0.3);
color: #48bb78;
}
.badge-down {
background: rgba(252, 129, 129, 0.12);
border-color: rgba(252, 129, 129, 0.3);
color: #fc8181;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
}
.dot-up {
background: #48bb78;
box-shadow: 0 0 5px #48bb78;
}
.dot-down {
background: #fc8181;
box-shadow: 0 0 5px #fc8181;
animation: pulse-dot 1.2s ease-in-out infinite;
}
.peer-meta {
display: flex;
flex-direction: column;
gap: 6px;
}
.meta-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.meta-label {
color: var(--muted);
font-weight: 500;
}
.meta-value {
color: var(--text);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 11px;
}
.down-warning {
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);
text-align: center;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
</style>

View File

@ -0,0 +1,362 @@
<template>
<div class="route-table">
<!-- Toolbar -->
<div class="toolbar">
<div class="route-count">
<span class="count-num">{{ filtered.length }}</span>
<span class="count-label"> / {{ props.routes.length }} routes</span>
</div>
<input
v-model="search"
class="search-input"
placeholder="Filter by prefix, AS path, community..."
@input="page = 1"
/>
<button class="btn-withdraw-all" @click="handleWithdrawAll" :disabled="pendingWithdrawAll">
<span v-if="pendingWithdrawAll" class="spinner-sm"></span>
Withdraw All
</button>
</div>
<div v-if="withdrawAllMsg" class="feedback" :class="withdrawAllMsg.type">
{{ withdrawAllMsg.msg }}
</div>
<!-- Table -->
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Prefix</th>
<th>Next Hop</th>
<th>AS Path</th>
<th>Communities</th>
<th>MED</th>
<th>Announced At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr v-if="!paginatedRoutes.length">
<td colspan="7" class="no-data">
{{ search ? 'No routes match filter.' : 'No active routes.' }}
</td>
</tr>
<tr v-for="route in paginatedRoutes" :key="route.prefix" class="route-row">
<td class="mono prefix-col">{{ route.prefix }}</td>
<td class="mono">{{ route.next_hop || '—' }}</td>
<td class="mono">{{ formatAspath(route.as_path) }}</td>
<td class="mono small">{{ formatCommunities(route.communities) }}</td>
<td class="mono">{{ route.med ?? '—' }}</td>
<td class="small ts">{{ formatTime(route.announced_at) }}</td>
<td>
<button
class="btn-withdraw-row"
:disabled="pendingWithdraw[route.prefix]"
@click="handleWithdraw(route.prefix)"
>
<span v-if="pendingWithdraw[route.prefix]" class="spinner-sm"></span>
<span v-else>&#x2715;</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination" v-if="totalPages > 1">
<button @click="page = 1" :disabled="page === 1" class="pg-btn">&#171;</button>
<button @click="page--" :disabled="page === 1" class="pg-btn">&#8249;</button>
<span class="pg-info">Page {{ page }} / {{ totalPages }}</span>
<button @click="page++" :disabled="page === totalPages" class="pg-btn">&#8250;</button>
<button @click="page = totalPages" :disabled="page === totalPages" class="pg-btn">&#187;</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, reactive } from 'vue'
import { api } from '../api.js'
const props = defineProps({
routes: { type: Array, default: () => [] },
})
const emit = defineEmits(['refresh'])
const search = ref('')
const page = ref(1)
const PAGE_SIZE = 20
const pendingWithdraw = reactive({})
const pendingWithdrawAll = ref(false)
const withdrawAllMsg = ref(null)
function formatAspath(aspath) {
if (!aspath || !aspath.length) return '—'
return Array.isArray(aspath) ? aspath.join(' ') : String(aspath)
}
function formatCommunities(comms) {
if (!comms || !comms.length) return '—'
return Array.isArray(comms) ? comms.join(' ') : String(comms)
}
function formatTime(ts) {
if (!ts) return '—'
try {
return new Date(ts).toLocaleString()
} catch {
return ts
}
}
const filtered = computed(() => {
const q = search.value.toLowerCase().trim()
if (!q) return props.routes
return props.routes.filter(r => {
const aspath = formatAspath(r.as_path).toLowerCase()
const communities = formatCommunities(r.communities).toLowerCase()
return (
(r.prefix || '').toLowerCase().includes(q) ||
aspath.includes(q) ||
communities.includes(q)
)
})
})
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / PAGE_SIZE)))
const paginatedRoutes = computed(() => {
const start = (page.value - 1) * PAGE_SIZE
return filtered.value.slice(start, start + PAGE_SIZE)
})
async function handleWithdraw(prefix) {
pendingWithdraw[prefix] = true
try {
await api.withdraw([prefix])
emit('refresh')
} catch (e) {
console.error('Withdraw failed:', e)
} finally {
delete pendingWithdraw[prefix]
}
}
function showWithdrawAllMsg(type, msg) {
withdrawAllMsg.value = { type, msg }
setTimeout(() => { withdrawAllMsg.value = null }, 4000)
}
async function handleWithdrawAll() {
if (!confirm('Withdraw ALL active routes?')) return
pendingWithdrawAll.value = true
try {
await api.withdrawAll()
showWithdrawAllMsg('success', 'All routes withdrawn.')
emit('refresh')
} catch (e) {
showWithdrawAllMsg('error', `Failed: ${e.message}`)
} finally {
pendingWithdrawAll.value = false
}
}
</script>
<style scoped>
.route-table {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.route-count {
white-space: nowrap;
font-size: 13px;
}
.count-num {
font-weight: 700;
color: var(--accent);
font-size: 15px;
}
.count-label {
color: var(--muted);
}
.search-input {
flex: 1;
min-width: 180px;
}
.btn-withdraw-all {
padding: 6px 14px;
background: rgba(252, 129, 129, 0.12);
color: #fc8181;
border: 1px solid rgba(252, 129, 129, 0.3);
font-weight: 600;
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.btn-withdraw-all:hover:not(:disabled) {
background: rgba(252, 129, 129, 0.22);
}
.table-wrapper {
overflow-x: auto;
overflow-y: auto;
flex: 1;
border: 1px solid var(--border);
border-radius: var(--radius);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
thead th {
position: sticky;
top: 0;
background: #1e2434;
color: var(--muted);
text-align: left;
padding: 8px 10px;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid rgba(45, 55, 72, 0.5);
transition: background 0.1s;
}
tbody tr:hover {
background: rgba(79, 156, 249, 0.04);
}
tbody td {
padding: 7px 10px;
color: var(--text);
vertical-align: middle;
}
.no-data {
text-align: center;
color: var(--muted);
padding: 32px;
}
.mono {
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
}
.small {
font-size: 11px;
}
.prefix-col {
font-weight: 600;
color: var(--accent);
}
.ts {
color: var(--muted);
}
.btn-withdraw-row {
background: rgba(252, 129, 129, 0.1);
color: #fc8181;
border: 1px solid rgba(252, 129, 129, 0.2);
padding: 3px 8px;
font-size: 12px;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 3px;
}
.btn-withdraw-row:hover:not(:disabled) {
background: rgba(252, 129, 129, 0.2);
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding-bottom: 4px;
}
.pg-btn {
background: var(--card-bg);
color: var(--text);
border: 1px solid var(--border);
padding: 4px 10px;
border-radius: 4px;
font-size: 14px;
}
.pg-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.pg-info {
color: var(--muted);
font-size: 12px;
padding: 0 6px;
}
.feedback {
font-size: 12px;
padding: 6px 12px;
border-radius: 4px;
}
.feedback.success {
background: rgba(72, 187, 120, 0.12);
color: #48bb78;
border: 1px solid rgba(72, 187, 120, 0.25);
}
.feedback.error {
background: rgba(252, 129, 129, 0.12);
color: #fc8181;
border: 1px solid rgba(252, 129, 129, 0.25);
}
.spinner-sm {
width: 11px;
height: 11px;
border: 2px solid rgba(255,255,255,0.2);
border-top-color: currentColor;
border-radius: 50%;
display: inline-block;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>

View File

@ -0,0 +1,319 @@
<template>
<div class="scenario-panel">
<div class="panel-header">
<h2 class="panel-title">Scenarios</h2>
<button class="btn-refresh" @click="fetchScenarios" :disabled="loading" title="Refresh scenarios">
<span :class="{ spin: loading }">&#8635;</span>
</button>
</div>
<div v-if="fetchError" class="fetch-error">{{ fetchError }}</div>
<div v-if="loading && !scenarioList.length" class="loading-state">
<span class="spinner"></span> Loading scenarios...
</div>
<div v-else-if="!scenarioList.length && !loading" class="empty-state">
No scenarios available.
</div>
<div v-for="s in scenarioList" :key="s.name" class="scenario-card">
<div class="card-header">
<div>
<div class="card-title">{{ formatName(s.name) }}</div>
<div class="card-desc">{{ s.description }}</div>
</div>
<div class="route-badge">{{ s.route_count }} routes</div>
</div>
<div class="card-actions">
<button
class="btn-load"
:disabled="pendingMap[s.name] === 'load'"
@click="handleLoad(s.name)"
>
<span v-if="pendingMap[s.name] === 'load'" class="spinner-sm"></span>
<span v-else>&#9654;</span>
Load
</button>
<button
class="btn-unload"
:disabled="pendingMap[s.name] === 'unload'"
@click="handleUnload(s.name)"
>
<span v-if="pendingMap[s.name] === 'unload'" class="spinner-sm"></span>
<span v-else>&#9632;</span>
Unload
</button>
</div>
<div v-if="feedbackMap[s.name]" class="feedback" :class="feedbackMap[s.name].type">
{{ feedbackMap[s.name].msg }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { api } from '../api.js'
const emit = defineEmits(['routes-changed'])
const scenarioList = ref([])
const loading = ref(false)
const fetchError = ref(null)
const pendingMap = reactive({})
const feedbackMap = reactive({})
function formatName(name) {
return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
async function fetchScenarios() {
loading.value = true
fetchError.value = null
try {
const data = await api.scenarios()
const scenarios = data.scenarios || {}
scenarioList.value = Object.entries(scenarios).map(([name, info]) => ({
name,
description: info.description || '',
route_count: info.route_count || 0,
}))
} catch (e) {
fetchError.value = `Failed to load scenarios: ${e.message}`
} finally {
loading.value = false
}
}
function showFeedback(name, type, msg) {
feedbackMap[name] = { type, msg }
setTimeout(() => {
delete feedbackMap[name]
}, 4000)
}
async function handleLoad(name) {
pendingMap[name] = 'load'
try {
const res = await api.loadScenario(name)
showFeedback(name, 'success', `Announced ${res.count ?? res.announced?.length ?? 0} routes`)
emit('routes-changed')
} catch (e) {
showFeedback(name, 'error', `Load failed: ${e.message}`)
} finally {
delete pendingMap[name]
}
}
async function handleUnload(name) {
pendingMap[name] = 'unload'
try {
const res = await api.unloadScenario(name)
showFeedback(name, 'warn', `Withdrawn ${res.count ?? res.withdrawn?.length ?? 0} routes`)
emit('routes-changed')
} catch (e) {
showFeedback(name, 'error', `Unload failed: ${e.message}`)
} finally {
delete pendingMap[name]
}
}
onMounted(fetchScenarios)
</script>
<style scoped>
.scenario-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.btn-refresh {
background: transparent;
color: var(--muted);
padding: 4px 8px;
font-size: 16px;
border-radius: 4px;
}
.btn-refresh:hover {
color: var(--accent);
background: rgba(79, 156, 249, 0.1);
}
.spin {
display: inline-block;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.fetch-error {
color: var(--danger);
font-size: 12px;
padding: 6px 10px;
background: rgba(252, 129, 129, 0.08);
border-radius: var(--radius);
border: 1px solid rgba(252, 129, 129, 0.2);
}
.loading-state, .empty-state {
color: var(--muted);
font-size: 13px;
text-align: center;
padding: 20px 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.scenario-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.card-title {
font-weight: 600;
color: var(--text);
font-size: 14px;
}
.card-desc {
color: var(--muted);
font-size: 12px;
margin-top: 2px;
line-height: 1.4;
}
.route-badge {
background: rgba(79, 156, 249, 0.12);
border: 1px solid rgba(79, 156, 249, 0.25);
color: var(--accent);
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.card-actions {
display: flex;
gap: 8px;
}
.btn-load {
flex: 1;
padding: 7px 0;
background: rgba(72, 187, 120, 0.15);
color: #48bb78;
border: 1px solid rgba(72, 187, 120, 0.3);
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
}
.btn-load:hover:not(:disabled) {
background: rgba(72, 187, 120, 0.25);
}
.btn-unload {
flex: 1;
padding: 7px 0;
background: rgba(252, 129, 129, 0.12);
color: #fc8181;
border: 1px solid rgba(252, 129, 129, 0.25);
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
}
.btn-unload:hover:not(:disabled) {
background: rgba(252, 129, 129, 0.22);
}
.feedback {
font-size: 12px;
padding: 5px 10px;
border-radius: 4px;
}
.feedback.success {
background: rgba(72, 187, 120, 0.12);
color: #48bb78;
border: 1px solid rgba(72, 187, 120, 0.25);
}
.feedback.warn {
background: rgba(246, 173, 85, 0.12);
color: #f6ad55;
border: 1px solid rgba(246, 173, 85, 0.25);
}
.feedback.error {
background: rgba(252, 129, 129, 0.12);
color: #fc8181;
border: 1px solid rgba(252, 129, 129, 0.25);
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255,255,255,0.2);
border-top-color: #fff;
border-radius: 50%;
display: inline-block;
animation: spin 0.7s linear infinite;
}
.spinner-sm {
width: 12px;
height: 12px;
border: 2px solid rgba(255,255,255,0.2);
border-top-color: currentColor;
border-radius: 50%;
display: inline-block;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>

View File

@ -0,0 +1,127 @@
<template>
<div class="status-bar">
<!-- API Status -->
<div class="badge" :class="apiError ? 'badge-danger' : 'badge-success'">
<span class="dot" :class="apiError ? 'dot-danger' : 'dot-success'"></span>
API: {{ apiError ? 'ERROR' : 'OK' }}
</div>
<!-- Active Routes -->
<div class="badge badge-neutral">
<span class="label">Routes:</span>
<span class="value">{{ health ? health.active_routes ?? 0 : '—' }}</span>
</div>
<!-- Peer Summary -->
<div class="badge" :class="peerBadgeClass">
<span class="dot" :class="peerDotClass"></span>
Peers: {{ peersUp }}/{{ peersTotal }} UP
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
health: { type: Object, default: null },
apiError: { type: String, default: null },
})
const peersTotal = computed(() => {
if (!props.health?.peers) return 0
return Object.keys(props.health.peers).length
})
const peersUp = computed(() => {
if (!props.health?.peers) return 0
return Object.values(props.health.peers).filter(p => p.state === 'up').length
})
const allUp = computed(() => peersTotal.value > 0 && peersUp.value === peersTotal.value)
const peerBadgeClass = computed(() => {
if (peersTotal.value === 0) return 'badge-neutral'
return allUp.value ? 'badge-success' : 'badge-danger'
})
const peerDotClass = computed(() => {
if (peersTotal.value === 0) return 'dot-neutral'
return allUp.value ? 'dot-success' : 'dot-danger'
})
</script>
<style scoped>
.status-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
border: 1px solid;
}
.badge-success {
background: rgba(72, 187, 120, 0.12);
border-color: rgba(72, 187, 120, 0.3);
color: #48bb78;
}
.badge-danger {
background: rgba(252, 129, 129, 0.12);
border-color: rgba(252, 129, 129, 0.3);
color: #fc8181;
}
.badge-neutral {
background: rgba(113, 128, 150, 0.12);
border-color: rgba(113, 128, 150, 0.3);
color: #a0aec0;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
}
.dot-success {
background: #48bb78;
box-shadow: 0 0 5px #48bb78;
}
.dot-danger {
background: #fc8181;
box-shadow: 0 0 5px #fc8181;
animation: pulse-dot 1.5s infinite;
}
.dot-neutral {
background: #718096;
}
.label {
color: #718096;
}
.value {
color: #e2e8f0;
font-weight: 700;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>

3
exabgp-ui/src/main.js Normal file
View File

@ -0,0 +1,3 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

14
exabgp-ui/vite.config.js Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://localhost:5050',
rewrite: path => path.replace(/^\/api/, '')
}
}
}
})

View File

@ -4,6 +4,7 @@ inject.py — CLI wrapper for the ExaBGP Route Injection API
Usage:
inject.py status
inject.py peers
inject.py routes
inject.py scenarios
inject.py announce <prefix> [<prefix>...] [--as-path ASN...] [--community STR...] [--med N] [--next-hop IP]
@ -12,6 +13,7 @@ Usage:
inject.py scenario <name>
inject.py withdraw-scenario <name>
inject.py churn [--count N] [--interval SEC] # cycle announce/withdraw for ip_rib_log population
inject.py monitor # live-refresh terminal view
Environment:
EXABGP_API=http://localhost:5050 API base URL
@ -53,6 +55,64 @@ def cmd_status(args):
_pp(_get('/healthz'))
def cmd_peers(args):
data = _get('/peers')
peers = data.get('peers', {})
if not peers:
print("No peer state received yet (ExaBGP may still be establishing sessions).")
return
print(f"{'Peer':<20} {'State':<8} {'Updated'}")
print('-' * 55)
for ip, info in peers.items():
state = info.get('state', 'unknown')
updated = info.get('updated', '-')
indicator = 'UP' if state == 'up' else 'DOWN'
print(f"{ip:<20} {indicator:<8} {updated}")
def cmd_monitor(args):
"""Live-refreshing terminal status view. Ctrl+C to exit."""
import shutil
print("OpenBMP ExaBGP Monitor (Ctrl+C to exit)\n")
try:
while True:
try:
health = _get('/healthz')
peers = health.get('peers', {})
active = health.get('active_routes', 0)
status = health.get('status', '?')
# Clear to start of previous output using ANSI codes
cols, _ = shutil.get_terminal_size(fallback=(80, 24))
peer_count = len(peers)
peers_up = sum(1 for p in peers.values() if p.get('state') == 'up')
lines = [
f" API: {status.upper():<8} Routes: {active:<6} Peers: {peers_up}/{peer_count} UP",
'',
]
for ip, info in peers.items():
state = info.get('state', 'unknown').upper()
updated = info.get('updated', '-')
lines.append(f" {ip:<22} {state:<6} {updated}")
lines.append('')
lines.append(f" Refreshing every 5s ... {time.strftime('%H:%M:%S')}")
output = '\n'.join(lines)
# Move cursor up to overwrite previous output
print(f"\033[{len(lines) + 1}A", end='')
print(output)
except requests.exceptions.ConnectionError:
print("\033[1A API: UNREACHABLE")
time.sleep(5)
except KeyboardInterrupt:
print("\n\nMonitor stopped.")
def cmd_routes(args):
data = _get('/routes')
print(f"Active routes: {data['count']}")
@ -153,9 +213,11 @@ def main():
)
sub = parser.add_subparsers(dest='command')
sub.add_parser('status', help='Show API health and peer states')
sub.add_parser('status', help='Show API health and peer states (JSON)')
sub.add_parser('peers', help='Show BGP peer states in a readable table')
sub.add_parser('routes', help='List active announced routes')
sub.add_parser('scenarios', help='List available scenarios')
sub.add_parser('monitor', help='Live-refreshing terminal status view')
sub.add_parser('withdraw-all', help='Withdraw all active routes')
p = sub.add_parser('announce', help='Announce one or more prefixes')
@ -184,8 +246,10 @@ def main():
cmds = {
'status': cmd_status,
'peers': cmd_peers,
'routes': cmd_routes,
'scenarios': cmd_scenarios,
'monitor': cmd_monitor,
'announce': cmd_announce,
'withdraw': cmd_withdraw,
'withdraw-all': cmd_withdraw_all,

View File

@ -283,6 +283,86 @@ _LAB_ROUTES = [
]
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Scenario: convergence_test
# 10 prefixes for timing BGP convergence.
# Announce with inject.py, observe arrival in ip_rib_log, then withdraw.
# Convergence time = delta between first announcement and stable state.
# ---------------------------------------------------------------------------
_CONVERGENCE_ROUTES = [
_r('192.168.100.0/24', [65100, 65200], communities=['65100:convergence']),
_r('192.168.101.0/24', [65100, 65200], communities=['65100:convergence']),
_r('192.168.102.0/24', [65100, 65200], communities=['65100:convergence']),
_r('192.168.103.0/24', [65100, 65200], communities=['65100:convergence']),
_r('192.168.104.0/24', [65100, 65200], communities=['65100:convergence']),
_r('192.168.105.0/24', [65100, 65200], communities=['65100:convergence']),
_r('192.168.106.0/24', [65100, 65200], communities=['65100:convergence']),
_r('192.168.107.0/24', [65100, 65200], communities=['65100:convergence']),
_r('192.168.108.0/24', [65100, 65200], communities=['65100:convergence']),
_r('192.168.109.0/24', [65100, 65200], communities=['65100:convergence']),
]
# ---------------------------------------------------------------------------
# Scenario: route_leak
# Simulates a route leak: real internet prefixes re-announced with a short
# (direct) AS path, as if an intermediate AS leaked them without proper
# filtering. Community 65100:999 tags these as "leaked".
# Learning: shows how a shorter AS path wins best-path selection even when
# the origin is unexpected. Watch the Grafana AS Path dashboard.
# ---------------------------------------------------------------------------
_ROUTE_LEAK_ROUTES = [
# Real prefixes, but announced with a single-hop path (leak simulation)
_r('8.8.8.0/24', [65100, 15169], communities=['65100:999']), # Google DNS — legit origin
_r('1.1.1.0/24', [65100, 13335], communities=['65100:999']), # Cloudflare — legit origin
_r('208.67.222.0/24', [65100, 36692], communities=['65100:999']), # OpenDNS
_r('9.9.9.0/24', [65100, 19281], communities=['65100:999']), # Quad9
_r('4.2.2.0/24', [65100, 3356], communities=['65100:999']), # Level3 DNS (leaked from transit)
_r('64.6.64.0/24', [65100, 19262], communities=['65100:999']), # Verisign
_r('156.154.70.0/24', [65100, 19318], communities=['65100:999']), # Neustar
_r('195.46.39.0/24', [65100, 21414], communities=['65100:999']), # SafeDNS
_r('216.146.35.0/24', [65100, 36692], communities=['65100:999']), # Dyn/Oracle
_r('77.88.8.0/24', [65100, 13238], communities=['65100:999']), # Yandex DNS
]
# ---------------------------------------------------------------------------
# Scenario: hijack_simulation
# Simulates a BGP prefix hijack: ExaBGP (AS 65100) announces a subset of
# the internet_sample prefixes with a *shorter* AS path than the legitimate
# announcements, mimicking an attacker claiming ownership.
# Community 65100:hijack marks these entries.
# Learning: demonstrates why shorter AS paths win, how RPKI prevents this,
# and why origin AS validation matters.
# Watch ip_rib on the CORE routers: the hijack paths should become bestpaths
# if they have a shorter AS path length than the existing legitimate routes.
# ---------------------------------------------------------------------------
_HIJACK_ROUTES = [
# Announcing Google prefixes as if originated directly from AS 65100
# (shorter path = wins best-path selection over the legitimate 3-hop paths)
_r('8.8.8.0/24', [65100], communities=['65100:hijack', '65100:999']),
_r('8.8.4.0/24', [65100], communities=['65100:hijack', '65100:999']),
_r('1.1.1.0/24', [65100], communities=['65100:hijack', '65100:999']),
_r('104.16.0.0/13', [65100], communities=['65100:hijack', '65100:999']),
_r('172.217.0.0/16', [65100], communities=['65100:hijack', '65100:999']),
# Announcing AWS prefixes
_r('52.0.0.0/14', [65100], communities=['65100:hijack', '65100:999']),
_r('54.64.0.0/13', [65100], communities=['65100:hijack', '65100:999']),
# Announcing Azure prefixes
_r('40.64.0.0/10', [65100], communities=['65100:hijack', '65100:999']),
_r('13.64.0.0/11', [65100], communities=['65100:hijack', '65100:999']),
# Announcing Cloudflare prefixes
_r('162.158.0.0/15', [65100], communities=['65100:hijack', '65100:999']),
]
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
@ -312,4 +392,16 @@ SCENARIOS = {
'description': 'Enterprise/SP-style routes with communities and local-pref for policy testing',
'routes': _LAB_ROUTES,
},
'convergence_test': {
'description': '10 prefixes for BGP convergence timing — announce, observe ip_rib_log, withdraw',
'routes': _CONVERGENCE_ROUTES,
},
'route_leak': {
'description': '10 real prefixes re-announced with short AS paths — simulates a route leak (community 65100:999)',
'routes': _ROUTE_LEAK_ROUTES,
},
'hijack_simulation': {
'description': '10 prefixes announced as if directly originated by AS 65100 — simulates a prefix hijack (community 65100:hijack)',
'routes': _HIJACK_ROUTES,
},
}

View File

@ -0,0 +1,160 @@
{
"annotations": {"list": [{"builtIn": 1,"datasource": {"type": "datasource","uid": "grafana"},"enable": true,"hide": true,"iconColor": "rgba(0, 211, 255, 1)","name": "Annotations & Alerts","type": "dashboard"}]},
"description": "AS path length distribution and analysis. Teaches how BGP AS paths reflect internet topology and how to detect anomalies like route leaks or AS path prepending.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: Internet routes typically have 2-5 hops. A /32 or /24 appearing with only 1-hop AS path from an unexpected ASN is a classic hijack indicator. Routes with 10+ hops may indicate prepending.",
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"fillOpacity": 80,"gradientMode": "none","lineWidth": 0},
"unit": "short"
}
},
"gridPos": {"h": 10,"w": 12,"x": 0,"y": 0},
"id": 1,
"options": {"barRadius": 0,"barWidth": 0.7,"groupWidth": 0.7,"legend": {"calcs": [],"displayMode": "list","placement": "bottom"},"orientation": "auto","tooltip": {"mode": "single"},"xTickLabelRotation": 0,"xTickLabelSpacing": 200},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n ba.as_path_count AS \"AS Path Length (hops)\",\n COUNT(*) AS \"Prefix Count\"\nFROM ip_rib r\nJOIN base_attrs ba ON ba.hash_id = r.base_attr_hash_id\nWHERE r.iswithdrawn = false\n AND r.isipv4 = true\n AND ba.as_path_count > 0\nGROUP BY ba.as_path_count\nORDER BY ba.as_path_count",
"refId": "A"
}
],
"title": "AS Path Length Distribution (Active IPv4 Routes)",
"type": "barchart"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: Average AS path length on the internet is ~4-5 hops. Your lab has shorter paths since ExaBGP is a single eBGP hop away.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 5},{"color": "red","value": 8}]},
"unit": "short",
"decimals": 1
}
},
"gridPos": {"h": 5,"w": 6,"x": 12,"y": 0},
"id": 2,
"options": {"colorMode": "value","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() AS time,\n ROUND(AVG(ba.as_path_count)::numeric, 1) AS \"Avg AS Path Length\"\nFROM ip_rib r\nJOIN base_attrs ba ON ba.hash_id = r.base_attr_hash_id\nWHERE r.iswithdrawn = false AND r.isipv4 = true AND ba.as_path_count > 0",
"refId": "A"
}
],
"title": "Average AS Path Length",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: Routes with only 1-hop AS path are directly connected or possibly hijacked. In your lab, ExaBGP injects routes starting with AS 65100.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 5},{"color": "red","value": 20}]},
"unit": "short"
}
},
"gridPos": {"h": 5,"w": 6,"x": 18,"y": 0},
"id": 3,
"options": {"colorMode": "value","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() AS time,\n COUNT(*) AS \"Direct (1-hop) Routes\"\nFROM ip_rib r\nJOIN base_attrs ba ON ba.hash_id = r.base_attr_hash_id\nWHERE r.iswithdrawn = false AND r.isipv4 = true AND ba.as_path_count = 1",
"refId": "A"
}
],
"title": "1-Hop Routes (Direct/Possible Hijack)",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: The longest paths reveal the most AS-level hops in your network. AS path prepending intentionally lengthens paths to make a route less preferred.",
"fieldConfig": {
"defaults": {"custom": {"align": "auto","displayMode": "auto"}},
"overrides": [
{"matcher": {"id": "byName","options": "AS Path Length"},"properties": [{"id": "custom.displayMode","value": "color-background"},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 5},{"color": "red","value": 10}]}}]},
{"matcher": {"id": "byName","options": "AS Path"},"properties": [{"id": "custom.width","value": 400}]}
]
},
"gridPos": {"h": 10,"w": 24,"x": 0,"y": 10},
"id": 4,
"options": {"footer": {"fields": "","reducer": ["sum"],"show": false},"showHeader": true,"sortBy": [{"desc": true,"displayName": "AS Path Length"}]},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n r.prefix AS \"Prefix\",\n ba.as_path_count AS \"AS Path Length\",\n ba.as_path::text AS \"AS Path\",\n ba.origin_as AS \"Origin AS\",\n ba.next_hop AS \"Next Hop\"\nFROM ip_rib r\nJOIN base_attrs ba ON ba.hash_id = r.base_attr_hash_id\nWHERE r.iswithdrawn = false AND r.isipv4 = true\nORDER BY ba.as_path_count DESC\nLIMIT 30",
"refId": "A"
}
],
"title": "Longest AS Paths (Top 30)",
"type": "table"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: Origin AS is the rightmost ASN in the AS path — the network that first originated the prefix. Most internet prefixes are originated by their owning organization.",
"fieldConfig": {
"defaults": {"custom": {"align": "auto","displayMode": "auto"}},
"overrides": [
{"matcher": {"id": "byName","options": "Route Count"},"properties": [{"id": "custom.displayMode","value": "lcd-gauge"},{"id": "custom.width","value": 200}]}
]
},
"gridPos": {"h": 12,"w": 12,"x": 0,"y": 20},
"id": 5,
"options": {"footer": {"fields": "","reducer": ["sum"],"show": false},"showHeader": true,"sortBy": [{"desc": true,"displayName": "Route Count"}]},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n ba.origin_as AS \"Origin AS\",\n COALESCE(ia.as_name, 'Unknown') AS \"AS Name\",\n COUNT(*) AS \"Route Count\"\nFROM ip_rib r\nJOIN base_attrs ba ON ba.hash_id = r.base_attr_hash_id\nLEFT JOIN info_asn ia ON ia.asn = ba.origin_as\nWHERE r.iswithdrawn = false AND r.isipv4 = true\nGROUP BY ba.origin_as, ia.as_name\nORDER BY COUNT(*) DESC\nLIMIT 20",
"refId": "A"
}
],
"title": "Top Origin ASNs by Route Count",
"type": "table"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: A transit AS (appearing frequently in AS paths but not as origin) is a carrier. The most frequent transit ASNs in your lab correspond to simulated Tier-1 carriers (174=Cogent, 3356=Lumen, 1299=Telia, etc.)",
"fieldConfig": {
"defaults": {"color": {"mode": "palette-classic"},"custom": {"fillOpacity": 80,"lineWidth": 0},"unit": "short"}
},
"gridPos": {"h": 12,"w": 12,"x": 12,"y": 20},
"id": 6,
"options": {"barRadius": 0,"barWidth": 0.7,"groupWidth": 0.7,"legend": {"calcs": [],"displayMode": "list","placement": "bottom"},"orientation": "horizontal","tooltip": {"mode": "single"},"xTickLabelRotation": 0,"xTickLabelSpacing": 200},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n asn_val AS \"Transit ASN\",\n COUNT(*) AS \"Appearances in AS Paths\"\nFROM ip_rib r\nJOIN base_attrs ba ON ba.hash_id = r.base_attr_hash_id\nCROSS JOIN LATERAL unnest(ba.as_path) AS asn_val\nWHERE r.iswithdrawn = false AND asn_val != ba.origin_as\nGROUP BY asn_val\nORDER BY COUNT(*) DESC\nLIMIT 15",
"refId": "A"
}
],
"title": "Most Common Transit ASNs",
"type": "barchart"
}
],
"schemaVersion": 36,
"style": "dark",
"tags": ["obmp","learning","bgp","as-path","topology"],
"time": {"from": "now-1h","to": "now"},
"timepicker": {},
"timezone": "browser",
"title": "AS Path Analysis",
"uid": "obmp-learn-03",
"version": 1
}

View File

@ -0,0 +1,201 @@
{
"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": "Explore BGP path attributes: communities, MED, local-pref and how they influence routing policy decisions.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"datasource": {"type": "datasource","uid": "grafana"},
"gridPos": {"h": 8,"w": 24,"x": 0,"y": 0},
"id": 1,
"options": {
"content": "## BGP Path Attributes — What They Mean\n\n### BGP Communities (RFC 1997)\nCommunities are 32-bit tags attached to routes, written as **ASN:value** (e.g., `65000:100`). They carry policy signals between routers and ASes.\n\n**Well-known communities:**\n| Community | Decimal | Meaning |\n|-----------|---------|----------|\n| `65535:0` | NO_EXPORT | Do not advertise outside this AS or confederation |\n| `65535:1` | NO_ADVERTISE | Do not advertise to any peer |\n| `65535:666` | BLACKHOLE | Drop traffic destined for this prefix (RFC 7999) |\n\nPrivate communities (e.g., `65001:200`) are operator-defined — they may encode region, customer tier, or traffic-engineering intent.\n\n### Local Preference (local-pref)\n- **Scope:** iBGP only — never sent to eBGP peers.\n- **Effect:** Higher local-pref wins. Default is **100**.\n- **Use case:** Prefer one upstream provider over another for all outbound traffic.\n\n### Multi-Exit Discriminator (MED)\n- **Scope:** Sent to directly connected eBGP peers to influence *inbound* traffic.\n- **Effect:** Lower MED wins (when comparing routes from the same AS).\n- **Use case:** Tell a peer which of your links to prefer when sending traffic to you.\n\n> **Tip:** Use the panels below to explore what communities and attributes are actually present in the current RIB. Run `inject.py attributes` to load routes with varied communities and MED values.",
"mode": "markdown"
},
"title": "BGP Attribute Reference — Communities, Local-Pref, MED",
"type": "text"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: Each row is a unique community string (format ASN:value) seen across all active routes. High route counts for a community mean many routes share that policy tag. Look for well-known communities: 65535:0 (NO_EXPORT), 65535:1 (NO_ADVERTISE), 65535:666 (BLACKHOLE).",
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"},"custom": {"align": "auto","displayMode": "auto"},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null}]}},
"overrides": [
{"matcher": {"id": "byName","options": "Routes Tagged"},"properties": [{"id": "custom.displayMode","value": "lcd-gauge"},{"id": "color","value": {"mode": "thresholds"}},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "blue","value": null},{"color": "green","value": 10},{"color": "yellow","value": 100}]}}]}
]
},
"gridPos": {"h": 11,"w": 12,"x": 0,"y": 8},
"id": 2,
"options": {"footer": {"fields": "","reducer": ["sum"],"show": false},"showHeader": true,"sortBy": [{"desc": true,"displayName": "Routes Tagged"}]},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n comm AS \"Community\",\n COUNT(*) AS \"Routes Tagged\"\nFROM base_attrs ba\nJOIN ip_rib r ON r.base_attr_hash_id = ba.hash_id\nCROSS JOIN LATERAL unnest(ba.community_list) AS comm\nWHERE r.iswithdrawn = false AND ba.community_list IS NOT NULL\nGROUP BY comm\nORDER BY COUNT(*) DESC\nLIMIT 30",
"refId": "A"
}
],
"title": "Top BGP Communities in Current RIB",
"type": "table"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: Routes with notable BGP attributes — tagged with communities or using non-default local-pref / MED values. These routes carry explicit policy information. Examine the Communities column for operator-defined tags and the Local Pref column to see traffic engineering decisions.",
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"},"custom": {"align": "auto","displayMode": "auto"},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null}]}},
"overrides": [
{"matcher": {"id": "byName","options": "Local Pref"},"properties": [{"id": "custom.displayMode","value": "color-text"},{"id": "color","value": {"mode": "thresholds"}},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 101},{"color": "red","value": 200}]}}]},
{"matcher": {"id": "byName","options": "MED"},"properties": [{"id": "custom.displayMode","value": "color-text"},{"id": "color","value": {"mode": "thresholds"}},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 100}]}}]}
]
},
"gridPos": {"h": 11,"w": 12,"x": 12,"y": 8},
"id": 3,
"options": {"footer": {"fields": "","reducer": ["sum"],"show": false},"showHeader": true},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n r.prefix::text AS \"Prefix\",\n ba.origin_as AS \"Origin AS\",\n ba.community_list::text AS \"Communities\",\n ba.local_pref AS \"Local Pref\",\n ba.med AS \"MED\",\n ba.as_path_count AS \"Path Length\"\nFROM base_attrs ba\nJOIN ip_rib r ON r.base_attr_hash_id = ba.hash_id\nWHERE r.iswithdrawn = false AND r.isipv4 = true\n AND (ba.community_list IS NOT NULL OR ba.med IS NOT NULL OR ba.local_pref IS NOT NULL)\nORDER BY r.prefix\nLIMIT 100",
"refId": "A"
}
],
"title": "Routes with Notable Attributes",
"type": "table"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: MED (Multi-Exit Discriminator) is used to influence inbound traffic from a directly connected AS. Lower MED is preferred. If most routes show 'Not Set', MED is not being used for traffic engineering. A single dominant MED value means a simple policy; many different values indicate fine-grained control.",
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"fillOpacity": 80,"lineWidth": 0},
"unit": "short"
}
},
"gridPos": {"h": 9,"w": 12,"x": 0,"y": 19},
"id": 4,
"options": {"barRadius": 0.1,"barWidth": 0.6,"groupWidth": 0.7,"legend": {"displayMode": "list","placement": "bottom"},"orientation": "auto","text": {},"tooltip": {"mode": "single"},"xTickLabelRotation": -30,"xTickLabelSpacing": 100},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n COALESCE(ba.med::text, 'Not Set') AS \"MED Value\",\n COUNT(*) AS \"Route Count\"\nFROM base_attrs ba\nJOIN ip_rib r ON r.base_attr_hash_id = ba.hash_id\nWHERE r.iswithdrawn = false AND r.isipv4 = true\nGROUP BY ba.med\nORDER BY ba.med NULLS LAST\nLIMIT 20",
"refId": "A"
}
],
"title": "MED Value Distribution",
"type": "barchart"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: Local preference is an iBGP attribute — it never crosses AS boundaries. Default is 100. Routes with local-pref above 100 are preferred over the default path; below 100 they are used as last-resort. Non-100 values indicate active traffic-engineering policy. Run 'inject.py attributes' to inject routes with varied local-pref values.",
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"fillOpacity": 80,"lineWidth": 0},
"unit": "short"
}
},
"gridPos": {"h": 9,"w": 12,"x": 12,"y": 19},
"id": 5,
"options": {"barRadius": 0.1,"barWidth": 0.6,"groupWidth": 0.7,"legend": {"displayMode": "list","placement": "bottom"},"orientation": "auto","text": {},"tooltip": {"mode": "single"},"xTickLabelRotation": -30,"xTickLabelSpacing": 100},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n COALESCE(ba.local_pref::text, 'Not Set') AS \"Local Pref\",\n COUNT(*) AS \"Route Count\"\nFROM base_attrs ba\nJOIN ip_rib r ON r.base_attr_hash_id = ba.hash_id\nWHERE r.iswithdrawn = false AND r.isipv4 = true\nGROUP BY ba.local_pref\nORDER BY ba.local_pref DESC NULLS LAST\nLIMIT 20",
"refId": "A"
}
],
"title": "Local Preference Value Distribution",
"type": "barchart"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: This count tells you how widely BGP communities are used in your network. A value of 0 means no community tagging — communities are an opt-in feature. Run 'inject.py attributes' to add routes with community strings.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute","steps": [{"color": "blue","value": null},{"color": "green","value": 1}]},
"unit": "short",
"mappings": []
}
},
"gridPos": {"h": 5,"w": 8,"x": 0,"y": 28},
"id": 6,
"options": {"colorMode": "background","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() as time, COUNT(*) AS \"Routes with Communities\"\nFROM base_attrs ba\nJOIN ip_rib r ON r.base_attr_hash_id = ba.hash_id\nWHERE r.iswithdrawn = false\n AND ba.community_list IS NOT NULL\n AND array_length(ba.community_list, 1) > 0",
"refId": "A"
}
],
"title": "Routes with Communities",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: The number of distinct community strings seen across all active routes. A diverse set indicates fine-grained policy tagging. A single value means one uniform policy tag is applied.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute","steps": [{"color": "blue","value": null},{"color": "green","value": 1},{"color": "yellow","value": 50}]},
"unit": "short",
"mappings": []
}
},
"gridPos": {"h": 5,"w": 8,"x": 8,"y": 28},
"id": 7,
"options": {"colorMode": "background","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() as time, COUNT(DISTINCT comm) AS \"Unique Communities\"\nFROM base_attrs ba\nJOIN ip_rib r ON r.base_attr_hash_id = ba.hash_id\nCROSS JOIN LATERAL unnest(ba.community_list) AS comm\nWHERE r.iswithdrawn = false",
"refId": "A"
}
],
"title": "Unique Community Values",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: Routes with a local-pref other than the default (100) have been explicitly policy-engineered. A high count here means your network actively uses local-pref to prefer specific paths. A value of 0 means all paths are at default preference.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 100},{"color": "red","value": 1000}]},
"unit": "short",
"mappings": []
}
},
"gridPos": {"h": 5,"w": 8,"x": 16,"y": 28},
"id": 8,
"options": {"colorMode": "background","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() as time, COUNT(*) AS \"Custom Local-Pref Routes\"\nFROM base_attrs ba\nJOIN ip_rib r ON r.base_attr_hash_id = ba.hash_id\nWHERE r.iswithdrawn = false\n AND ba.local_pref IS NOT NULL\n AND ba.local_pref != 100",
"refId": "A"
}
],
"title": "Routes with Non-Default Local-Pref",
"type": "stat"
}
],
"schemaVersion": 36,
"style": "dark",
"tags": ["obmp","learning","bgp","communities","attributes","policy"],
"time": {"from": "now-1h","to": "now"},
"timepicker": {},
"timezone": "browser",
"title": "BGP Attribute Explorer",
"uid": "obmp-learn-06",
"version": 1
}

View File

@ -0,0 +1,152 @@
{
"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": "Prefix stability analysis and route churn visualization. Teaches how to identify unstable routes and understand BGP churn.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: This chart shows BGP advertisements and withdrawals bucketed per hour. A healthy network has steady low churn. Spikes in withdrawals indicate route instability events — link failures, IBGP reconvergence, or policy changes. Run 'inject.py churn' to generate synthetic churn data and observe it here.",
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "bars","fillOpacity": 60,"lineWidth": 1,"spanNulls": false,"stacking": {"group": "A","mode": "none"}},
"unit": "short"
},
"overrides": [
{"matcher": {"id": "byName","options": "Advertisements"},"properties": [{"id": "color","value": {"fixedColor": "green","mode": "fixed"}}]},
{"matcher": {"id": "byName","options": "Withdrawals"},"properties": [{"id": "color","value": {"fixedColor": "red","mode": "fixed"}}]}
]
},
"gridPos": {"h": 9,"w": 24,"x": 0,"y": 0},
"id": 1,
"options": {"legend": {"calcs": ["sum","max"],"displayMode": "list","placement": "bottom"},"tooltip": {"mode": "multi"}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT\n $__timeGroupAlias(timestamp,'1h'),\n SUM(CASE WHEN iswithdrawn = false THEN 1 ELSE 0 END) AS \"Advertisements\",\n SUM(CASE WHEN iswithdrawn = true THEN 1 ELSE 0 END) AS \"Withdrawals\"\nFROM ip_rib_log\nWHERE $__timeFilter(timestamp)\nGROUP BY 1\nORDER BY 1",
"refId": "A"
}
],
"title": "Advertisements vs Withdrawals Rate (per hour)",
"type": "timeseries"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: A prefix with more than 30 updates per day is considered unstable — it is flapping or being re-announced frequently. The Stability column categorizes each prefix. Run 'inject.py churn' to generate churn data and observe it here. Sort by 'Total Updates' to find the most problematic prefixes.",
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"},"custom": {"align": "auto","displayMode": "auto"},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null}]}},
"overrides": [
{"matcher": {"id": "byName","options": "Stability"},"properties": [{"id": "custom.displayMode","value": "color-text"},{"id": "mappings","value": [{"options": {"Very Stable": {"color": "green","index": 0},"Stable": {"color": "blue","index": 1},"Moderate": {"color": "yellow","index": 2},"Unstable": {"color": "red","index": 3}},"type": "value"}]}]},
{"matcher": {"id": "byName","options": "Total Updates"},"properties": [{"id": "custom.displayMode","value": "lcd-gauge"},{"id": "color","value": {"mode": "thresholds"}},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 7},{"color": "red","value": 30}]}}]}
]
},
"gridPos": {"h": 12,"w": 24,"x": 0,"y": 9},
"id": 2,
"options": {"footer": {"fields": "","reducer": ["sum"],"show": false},"showHeader": true,"sortBy": [{"desc": true,"displayName": "Total Updates"}]},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n prefix::text AS \"Prefix\",\n COUNT(*) AS \"Total Updates\",\n SUM(CASE WHEN iswithdrawn THEN 1 ELSE 0 END) AS \"Withdrawals\",\n SUM(CASE WHEN NOT iswithdrawn THEN 1 ELSE 0 END) AS \"Announcements\",\n MAX(timestamp) AS \"Last Change\",\n CASE\n WHEN COUNT(*) = 1 THEN 'Very Stable'\n WHEN COUNT(*) <= 7 THEN 'Stable'\n WHEN COUNT(*) <= 30 THEN 'Moderate'\n ELSE 'Unstable'\n END AS \"Stability\"\nFROM ip_rib_log\nWHERE $__timeFilter(timestamp)\nGROUP BY prefix\nORDER BY \"Total Updates\" DESC\nLIMIT 100",
"refId": "A"
}
],
"title": "Top Churning Prefixes",
"type": "table"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: This bar chart shows how many prefixes fall into each stability tier. In a healthy network, the vast majority of prefixes should be 'Very Stable' (only announced once during the window). A large 'Unstable' bar is a red flag. Run 'inject.py churn' to shift prefixes into the Unstable tier.",
"fieldConfig": {
"defaults": {
"color": {"mode": "fixed","fixedColor": "blue"},
"custom": {"fillOpacity": 80,"lineWidth": 0},
"unit": "short"
},
"overrides": [
{"matcher": {"id": "byName","options": "1. Very Stable (1 update)"},"properties": [{"id": "color","value": {"fixedColor": "green","mode": "fixed"}}]},
{"matcher": {"id": "byName","options": "2. Stable (2-7 updates)"},"properties": [{"id": "color","value": {"fixedColor": "blue","mode": "fixed"}}]},
{"matcher": {"id": "byName","options": "3. Moderate (8-30 updates)"},"properties": [{"id": "color","value": {"fixedColor": "yellow","mode": "fixed"}}]},
{"matcher": {"id": "byName","options": "4. Unstable (31+ updates)"},"properties": [{"id": "color","value": {"fixedColor": "red","mode": "fixed"}}]}
]
},
"gridPos": {"h": 9,"w": 14,"x": 0,"y": 21},
"id": 3,
"options": {"barRadius": 0.1,"barWidth": 0.6,"groupWidth": 0.7,"legend": {"displayMode": "list","placement": "bottom"},"orientation": "auto","text": {},"tooltip": {"mode": "single"},"xTickLabelRotation": 0,"xTickLabelSpacing": 200},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n CASE\n WHEN cnt = 1 THEN '1. Very Stable (1 update)'\n WHEN cnt <= 7 THEN '2. Stable (2-7 updates)'\n WHEN cnt <= 30 THEN '3. Moderate (8-30 updates)'\n ELSE '4. Unstable (31+ updates)'\n END AS \"Stability Tier\",\n COUNT(*) AS \"Prefix Count\"\nFROM (\n SELECT prefix, COUNT(*) as cnt\n FROM ip_rib_log\n WHERE $__timeFilter(timestamp)\n GROUP BY prefix\n) sub\nGROUP BY 1\nORDER BY 1",
"refId": "A"
}
],
"title": "Prefix Distribution by Stability Tier",
"type": "barchart"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: This is the single most churning prefix in the selected time range. If a prefix appears here repeatedly across time ranges, it may warrant investigation — check the AS path and peers announcing it.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute","steps": [{"color": "red","value": null}]},
"unit": "string",
"mappings": []
}
},
"gridPos": {"h": 5,"w": 10,"x": 14,"y": 21},
"id": 4,
"options": {"colorMode": "background","graphMode": "none","justifyMode": "center","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {"titleSize": 14,"valueSize": 18}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() AS time, prefix::text AS \"Most Churned Prefix\"\nFROM ip_rib_log\nWHERE $__timeFilter(timestamp)\nGROUP BY prefix\nORDER BY COUNT(*) DESC\nLIMIT 1",
"refId": "A"
}
],
"title": "Most Churned Prefix",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: This counts how many distinct prefixes had at least one update event in the selected time window. During a normal steady state this number should be low. After a major routing event (e.g., upstream link failure) you may see thousands of prefixes change simultaneously.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 500},{"color": "red","value": 2000}]},
"unit": "short",
"mappings": []
}
},
"gridPos": {"h": 4,"w": 10,"x": 14,"y": 26},
"id": 5,
"options": {"colorMode": "background","graphMode": "area","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() AS time, COUNT(DISTINCT prefix) AS \"Prefixes with Updates\"\nFROM ip_rib_log\nWHERE $__timeFilter(timestamp)",
"refId": "A"
}
],
"title": "Total Unique Prefixes with Updates",
"type": "stat"
}
],
"schemaVersion": 36,
"style": "dark",
"tags": ["obmp","learning","bgp","churn","stability"],
"time": {"from": "now-24h","to": "now"},
"timepicker": {},
"timezone": "browser",
"title": "Route Churn & Stability Score",
"uid": "obmp-learn-05",
"version": 1
}

View File

@ -0,0 +1,144 @@
{
"annotations": {"list": [{"builtIn": 1,"datasource": {"type": "datasource","uid": "grafana"},"enable": true,"hide": true,"iconColor": "rgba(0, 211, 255, 1)","name": "Annotations & Alerts","type": "dashboard"}]},
"description": "BGP peer session health, uptime, and flap analysis. Teaches session stability and how to diagnose flapping peers.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: A healthy BGP mesh shows all peers UP continuously. Any gap in the UP state represents a session flap — investigate the reset reason.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"custom": {"fillOpacity": 70,"lineWidth": 0,"spanNulls": false},
"mappings": [{"options": {"down": {"color": "red","index": 1,"text": "DOWN"},"up": {"color": "green","index": 0,"text": "UP"}},"type": "value"}],
"thresholds": {"mode": "absolute","steps": [{"color": "red","value": null},{"color": "green","value": 1}]}
}
},
"gridPos": {"h": 8,"w": 24,"x": 0,"y": 0},
"id": 1,
"options": {"alignValue": "left","legend": {"displayMode": "list","placement": "bottom"},"mergeValues": true,"rowHeight": 0.9,"showValue": "auto","tooltip": {"mode": "single"}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT\n $__timeGroupAlias(e.timestamp,'1m'),\n COALESCE(p.name, p.peer_addr::text) AS metric,\n CASE WHEN e.state = 'up' THEN 1 ELSE 0 END AS \"value\"\nFROM peer_event_log e\nJOIN bgp_peers p ON p.hash_id = e.peer_hash_id\nWHERE $__timeFilter(e.timestamp)\nORDER BY 1, 2",
"refId": "A"
}
],
"title": "Peer Session State Timeline",
"type": "state-timeline"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Current state of all BGP peers. Learn: 'bmp_reason' tells you why BMP reporting stopped. 'bgp_err_code' shows BGP NOTIFICATION error codes.",
"fieldConfig": {
"defaults": {"custom": {"align": "auto","displayMode": "auto"}},
"overrides": [
{"matcher": {"id": "byName","options": "State"},"properties": [{"id": "custom.displayMode","value": "color-background"},{"id": "mappings","value": [{"options": {"down": {"color": "red","index": 1,"text": "DOWN"},"up": {"color": "green","index": 0,"text": "UP"}},"type": "value"}]}]},
{"matcher": {"id": "byName","options": "Peer"},"properties": [{"id": "custom.width","value": 200}]},
{"matcher": {"id": "byName","options": "AS"},"properties": [{"id": "custom.width","value": 80}]}
]
},
"gridPos": {"h": 12,"w": 24,"x": 0,"y": 8},
"id": 2,
"options": {"footer": {"fields": "","reducer": ["sum"],"show": false},"showHeader": true,"sortBy": [{"desc": false,"displayName": "State"}]},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n COALESCE(p.name, p.peer_addr::text) AS \"Peer\",\n p.peer_addr AS \"Address\",\n p.peer_as AS \"AS\",\n p.state AS \"State\",\n p.timestamp AS \"Last State Change\",\n p.error_text AS \"Last Error\",\n p.local_hold_time AS \"Hold Time\"\nFROM bgp_peers p\nWHERE p.isprepolicy = true\nORDER BY p.state, p.peer_addr",
"refId": "A"
}
],
"title": "Current Peer State",
"type": "table"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: Flap count = number of times a peer went from UP to DOWN. A peer flapping more than 2 times per hour needs investigation.",
"fieldConfig": {
"defaults": {"custom": {"align": "auto","displayMode": "auto"}},
"overrides": [
{"matcher": {"id": "byName","options": "Flap Count"},"properties": [{"id": "custom.displayMode","value": "color-background"},{"id": "thresholds","value": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 1},{"color": "red","value": 5}]}}]}
]
},
"gridPos": {"h": 10,"w": 24,"x": 0,"y": 20},
"id": 3,
"options": {"footer": {"fields": "","reducer": ["sum"],"show": false},"showHeader": true,"sortBy": [{"desc": true,"displayName": "Flap Count"}]},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n COALESCE(p.name, p.peer_addr::text) AS \"Peer\",\n p.peer_addr AS \"Address\",\n p.peer_as AS \"AS\",\n COUNT(CASE WHEN e.state = 'down' THEN 1 END) AS \"Flap Count\",\n MIN(e.timestamp) AS \"First Event\",\n MAX(e.timestamp) AS \"Last Event\"\nFROM peer_event_log e\nJOIN bgp_peers p ON p.hash_id = e.peer_hash_id\nWHERE $__timeFilter(e.timestamp)\nGROUP BY p.name, p.peer_addr, p.peer_as\nORDER BY \"Flap Count\" DESC",
"refId": "A"
}
],
"title": "Peer Flap Analysis",
"type": "table"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"fieldConfig": {"defaults": {"color": {"mode": "thresholds"},"thresholds": {"mode": "absolute","steps": [{"color": "red","value": null},{"color": "yellow","value": 50},{"color": "green","value": 90}]},"unit": "percent","max": 100,"min": 0}},
"gridPos": {"h": 8,"w": 8,"x": 0,"y": 30},
"id": 4,
"options": {"orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"showThresholdLabels": false,"showThresholdMarkers": true,"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() AS time,\n ROUND(100.0 * SUM(CASE WHEN state = 'up' THEN 1 ELSE 0 END) / NULLIF(COUNT(*),0), 1) AS \"Mesh Health %\"\nFROM bgp_peers WHERE isprepolicy = true",
"refId": "A"
}
],
"title": "Overall Peer Mesh Health",
"type": "gauge"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"fieldConfig": {"defaults": {"color": {"mode": "thresholds"},"thresholds": {"mode": "absolute","steps": [{"color": "red","value": null},{"color": "green","value": 1}]},"unit": "short","mappings": [{"options": {"0": {"color": "red","index": 0,"text": "DOWN"}},"type": "value"}]}},
"gridPos": {"h": 8,"w": 8,"x": 8,"y": 30},
"id": 5,
"options": {"colorMode": "background","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() AS time,\n SUM(CASE WHEN state = 'up' THEN 1 ELSE 0 END) AS \"Peers UP\"\nFROM bgp_peers WHERE isprepolicy = true",
"refId": "A"
}
],
"title": "Peers Currently UP",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"fieldConfig": {"defaults": {"color": {"mode": "thresholds"},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 1},{"color": "red","value": 5}]},"unit": "short"}},
"gridPos": {"h": 8,"w": 8,"x": 16,"y": 30},
"id": 6,
"options": {"colorMode": "background","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() AS time,\n COUNT(CASE WHEN state = 'down' THEN 1 END) AS \"Flap Events (24h)\"\nFROM peer_event_log\nWHERE timestamp > NOW() - INTERVAL '24 hours' AND state = 'down'",
"refId": "A"
}
],
"title": "Flap Events (24h)",
"type": "stat"
}
],
"schemaVersion": 36,
"style": "dark",
"tags": ["obmp","learning","bgp","peers","flap"],
"time": {"from": "now-24h","to": "now"},
"timepicker": {},
"timezone": "browser",
"title": "Peer Session Health & Flap Analysis",
"uid": "obmp-learn-02",
"version": 1
}

View File

@ -0,0 +1,150 @@
{
"annotations": {"list": [{"builtIn": 1,"datasource": {"type": "datasource","uid": "grafana"},"enable": true,"hide": true,"iconColor": "rgba(0, 211, 255, 1)","name": "Annotations & Alerts","type": "dashboard"}]},
"description": "RPKI (Resource Public Key Infrastructure) validation status. Teaches BGP routing security and how RPKI prevents prefix hijacks by validating route origin.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"content": "## What is RPKI?\n\nRPKI (Resource Public Key Infrastructure) is a cryptographic security framework for BGP routing. It lets IP address holders publish **Route Origin Authorizations (ROAs)** stating which ASNs are authorized to originate their prefixes.\n\n### RPKI Validation States\n| State | Meaning |\n|-------|----------|\n| **Valid** | The route's origin AS matches a ROA for this prefix |\n| **Invalid** | A ROA exists but the origin AS or prefix length does NOT match — this route is potentially a hijack |\n| **NotFound** | No ROA exists for this prefix/origin — unprotected, can't be validated |\n\n### How to read this dashboard\n- **Valid %** should be as high as possible (target: 100%)\n- **Invalid routes** are critical — they indicate either a misconfiguration or a prefix hijack\n- Routes with no RPKI data show as **NotFound** — they are not necessarily invalid, just unprotected\n\n> **Lab note:** The RPKI validator table is populated by a cron job in psql-app every 2 hours. If the table shows 0 rows, wait for the cron to run or check `ENABLE_RPKI=1` in docker-compose.yml.",
"datasource": {"type": "datasource","uid": "grafana"},
"gridPos": {"h": 10,"w": 8,"x": 0,"y": 0},
"id": 1,
"options": {"content": "## What is RPKI?\n\nRPKI (Resource Public Key Infrastructure) is a cryptographic security framework for BGP routing. It lets IP address holders publish **Route Origin Authorizations (ROAs)** stating which ASNs are authorized to originate their prefixes.\n\n### RPKI Validation States\n| State | Meaning |\n|-------|----------|\n| **Valid** | The route's origin AS matches a ROA for this prefix |\n| **Invalid** | A ROA exists but the origin AS or prefix length does NOT match — this route is potentially a hijack |\n| **NotFound** | No ROA exists for this prefix/origin — unprotected, can't be validated |\n\n### How to read this dashboard\n- **Valid %** should be as high as possible (target: 100%)\n- **Invalid routes** are critical — they indicate either a misconfiguration or a prefix hijack\n- Routes with no RPKI data show as **NotFound** — they are not necessarily invalid, just unprotected\n\n> **Lab note:** The RPKI validator table is populated by a cron job in psql-app every 2 hours. If the table shows 0 rows, wait for the cron to run or check `ENABLE_RPKI=1` in docker-compose.yml.","mode": "markdown"},
"pluginVersion": "9.1.7",
"title": "RPKI Learning Guide",
"type": "text"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Total ROAs (Route Origin Authorizations) loaded from the RPKI validator. If 0, the cron job has not yet run.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute","steps": [{"color": "red","value": null},{"color": "yellow","value": 1},{"color": "green","value": 100000}]},
"unit": "short"
}
},
"gridPos": {"h": 5,"w": 4,"x": 8,"y": 0},
"id": 2,
"options": {"colorMode": "background","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() AS time, COUNT(*) AS \"RPKI ROAs Loaded\" FROM rpki_validator",
"refId": "A"
}
],
"title": "RPKI ROAs Loaded",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Routes with a matching valid ROA — origin AS and prefix length both match.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute","steps": [{"color": "red","value": null},{"color": "green","value": 1}]},
"unit": "short"
}
},
"gridPos": {"h": 5,"w": 4,"x": 12,"y": 0},
"id": 3,
"options": {"colorMode": "background","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() AS time, COUNT(*) AS \"Valid Routes\"\nFROM ip_rib r\nJOIN base_attrs ba ON ba.hash_id = r.base_attr_hash_id\nJOIN rpki_validator rv ON rv.prefix >>= r.prefix AND rv.origin_as = ba.origin_as AND r.prefix_len <= rv.prefix_len_max\nWHERE r.iswithdrawn = false AND r.isipv4 = true",
"refId": "A"
}
],
"title": "RPKI Valid Routes",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Routes where a ROA exists but the origin AS does NOT match — high-priority investigation needed.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "red","value": 1}]},
"unit": "short"
}
},
"gridPos": {"h": 5,"w": 4,"x": 16,"y": 0},
"id": 4,
"options": {"colorMode": "background","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() AS time, COUNT(*) AS \"RPKI Invalid Routes\"\nFROM ip_rib r\nJOIN base_attrs ba ON ba.hash_id = r.base_attr_hash_id\nWHERE r.iswithdrawn = false AND r.isipv4 = true\n AND EXISTS (\n SELECT 1 FROM rpki_validator rv\n WHERE rv.prefix >>= r.prefix AND rv.origin_as != ba.origin_as\n )\n AND NOT EXISTS (\n SELECT 1 FROM rpki_validator rv\n WHERE rv.prefix >>= r.prefix AND rv.origin_as = ba.origin_as AND r.prefix_len <= rv.prefix_len_max\n )",
"refId": "A"
}
],
"title": "RPKI Invalid Routes",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: ExaBGP-injected routes (AS 65100) will be NotFound since they use synthetic ASNs not registered in RPKI. Real internet prefixes with valid ROAs will appear as Valid.",
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"hideFrom": {"legend": false,"tooltip": false,"viz": false}},
"mappings": []
},
"overrides": []
},
"gridPos": {"h": 10,"w": 10,"x": 0,"y": 10},
"id": 5,
"options": {"displayLabels": ["percent","name"],"legend": {"displayMode": "list","placement": "bottom"},"pieType": "donut","tooltip": {"mode": "single"}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n CASE\n WHEN rv_valid.prefix IS NOT NULL THEN 'Valid'\n WHEN rv_any.prefix IS NOT NULL THEN 'Invalid'\n ELSE 'NotFound'\n END AS \"RPKI Status\",\n COUNT(*) AS \"Route Count\"\nFROM ip_rib r\nJOIN base_attrs ba ON ba.hash_id = r.base_attr_hash_id\nLEFT JOIN rpki_validator rv_valid\n ON rv_valid.prefix >>= r.prefix AND rv_valid.origin_as = ba.origin_as AND r.prefix_len <= rv_valid.prefix_len_max\nLEFT JOIN rpki_validator rv_any\n ON rv_any.prefix >>= r.prefix AND rv_any.origin_as != ba.origin_as\nWHERE r.iswithdrawn = false AND r.isipv4 = true\nGROUP BY 1\nORDER BY 1",
"refId": "A"
}
],
"title": "RPKI Validation Status Distribution",
"type": "piechart"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Prefixes that have a ROA but the observed origin AS does not match. These are the most security-critical routes — each one represents a potential hijack or misconfiguration.",
"fieldConfig": {
"defaults": {"custom": {"align": "auto","displayMode": "auto"}},
"overrides": [
{"matcher": {"id": "byName","options": "Status"},"properties": [{"id": "custom.displayMode","value": "color-background"},{"id": "mappings","value": [{"options": {"Invalid": {"color": "red","index": 0},"Valid": {"color": "green","index": 1},"NotFound": {"color": "yellow","index": 2}},"type": "value"}]}]}
]
},
"gridPos": {"h": 14,"w": 14,"x": 10,"y": 10},
"id": 6,
"options": {"footer": {"fields": "","reducer": ["sum"],"show": false},"showHeader": true},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "table",
"rawSql": "SELECT\n r.prefix AS \"Prefix\",\n ba.origin_as AS \"Observed Origin AS\",\n rv.origin_as AS \"Authorized Origin AS (ROA)\",\n 'Invalid' AS \"Status\"\nFROM ip_rib r\nJOIN base_attrs ba ON ba.hash_id = r.base_attr_hash_id\nJOIN rpki_validator rv ON rv.prefix >>= r.prefix AND rv.origin_as != ba.origin_as\nWHERE r.iswithdrawn = false AND r.isipv4 = true\n AND NOT EXISTS (\n SELECT 1 FROM rpki_validator rv2\n WHERE rv2.prefix >>= r.prefix AND rv2.origin_as = ba.origin_as AND r.prefix_len <= rv2.prefix_len_max\n )\nORDER BY r.prefix\nLIMIT 50",
"refId": "A"
}
],
"title": "RPKI Invalid Routes — Potential Hijacks",
"type": "table"
}
],
"schemaVersion": 36,
"style": "dark",
"tags": ["obmp","learning","bgp","rpki","security"],
"time": {"from": "now-1h","to": "now"},
"timepicker": {},
"timezone": "browser",
"title": "RPKI Validation Status",
"uid": "obmp-learn-04",
"version": 1
}

View File

@ -0,0 +1,137 @@
{
"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": "BGP update and withdrawal rates over time. Teaches what normal BGP traffic looks like and how to detect route churn or instability.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: A healthy network has far more advertisements than withdrawals. A withdrawal spike often signals a link failure or route flap.",
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "bars","fillOpacity": 60,"lineWidth": 1,"spanNulls": false,"stacking": {"group": "A","mode": "none"}},
"unit": "short"
},
"overrides": [
{"matcher": {"id": "byName","options": "Advertisements"},"properties": [{"id": "color","value": {"fixedColor": "green","mode": "fixed"}}]},
{"matcher": {"id": "byName","options": "Withdrawals"},"properties": [{"id": "color","value": {"fixedColor": "red","mode": "fixed"}}]}
]
},
"gridPos": {"h": 10,"w": 24,"x": 0,"y": 0},
"id": 1,
"options": {"legend": {"calcs": ["sum","max"],"displayMode": "list","placement": "bottom"},"tooltip": {"mode": "multi"}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT\n $__timeGroupAlias(timestamp,'5m'),\n SUM(CASE WHEN iswithdrawn = false THEN 1 ELSE 0 END) AS \"Advertisements\",\n SUM(CASE WHEN iswithdrawn = true THEN 1 ELSE 0 END) AS \"Withdrawals\"\nFROM ip_rib_log\nWHERE $__timeFilter(timestamp)\nGROUP BY 1\nORDER BY 1",
"refId": "A"
}
],
"title": "BGP Updates Over Time — Advertisements vs Withdrawals",
"type": "timeseries"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"fieldConfig": {"defaults": {"color": {"mode": "thresholds"},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 100},{"color": "red","value": 1000}]},"unit": "short","mappings": []}},
"gridPos": {"h": 5,"w": 6,"x": 0,"y": 10},
"id": 2,
"options": {"colorMode": "background","graphMode": "area","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() AS time, COUNT(*) AS \"Total Updates (24h)\" FROM ip_rib_log WHERE timestamp > NOW() - INTERVAL '24 hours'",
"refId": "A"
}
],
"title": "Total Updates (24h)",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Learn: Withdrawal rate above 30% is unusual. Above 50% may indicate a route leak or oscillation event.",
"fieldConfig": {"defaults": {"color": {"mode": "thresholds"},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 20},{"color": "red","value": 50}]},"unit": "percent","max": 100}},
"gridPos": {"h": 5,"w": 6,"x": 6,"y": 10},
"id": 3,
"options": {"colorMode": "background","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() AS time,\n ROUND(100.0 * SUM(CASE WHEN iswithdrawn THEN 1 ELSE 0 END) / NULLIF(COUNT(*),0), 1) AS \"Withdrawal Rate %\"\nFROM ip_rib_log\nWHERE timestamp > NOW() - INTERVAL '24 hours'",
"refId": "A"
}
],
"title": "Withdrawal Rate % (24h)",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"fieldConfig": {"defaults": {"color": {"mode": "thresholds"},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 1000},{"color": "red","value": 10000}]},"unit": "short"}},
"gridPos": {"h": 5,"w": 6,"x": 12,"y": 10},
"id": 4,
"options": {"colorMode": "value","graphMode": "area","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() AS time, COUNT(DISTINCT peer_hash_id) AS \"Active Peers\" FROM ip_rib_log WHERE timestamp > NOW() - INTERVAL '1 hour'",
"refId": "A"
}
],
"title": "Active Reporting Peers (1h)",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"fieldConfig": {"defaults": {"color": {"mode": "thresholds"},"thresholds": {"mode": "absolute","steps": [{"color": "green","value": null},{"color": "yellow","value": 500},{"color": "red","value": 2000}]},"unit": "short"}},
"gridPos": {"h": 5,"w": 6,"x": 18,"y": 10},
"id": 5,
"options": {"colorMode": "value","graphMode": "none","justifyMode": "auto","orientation": "auto","reduceOptions": {"calcs": ["lastNotNull"],"fields": "","values": false},"text": {}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT NOW() AS time, COUNT(DISTINCT prefix) AS \"Unique Prefixes Updated (24h)\" FROM ip_rib_log WHERE timestamp > NOW() - INTERVAL '24 hours'",
"refId": "A"
}
],
"title": "Unique Prefixes Updated (24h)",
"type": "stat"
},
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"description": "Updates per peer over time. Learn: Peers should have similar update rates. A peer with dramatically more updates may be experiencing instability or receiving a full BGP table with frequent changes.",
"fieldConfig": {
"defaults": {"color": {"mode": "palette-classic"},"custom": {"drawStyle": "line","fillOpacity": 10,"lineWidth": 1,"spanNulls": false},"unit": "short"}
},
"gridPos": {"h": 9,"w": 24,"x": 0,"y": 15},
"id": 6,
"options": {"legend": {"calcs": [],"displayMode": "list","placement": "right"},"tooltip": {"mode": "multi"}},
"targets": [
{
"datasource": {"type": "postgres","uid": "obmp_postgres"},
"format": "time_series",
"rawSql": "SELECT\n $__timeGroupAlias(s.interval_time,'30m'),\n COALESCE(p.name, p.peer_addr::text) AS metric,\n SUM(s.advertise_avg + s.withdraw_avg) AS \"Updates\"\nFROM stats_peer_update_counts s\nJOIN bgp_peers p ON p.hash_id = s.peer_hash_id\nWHERE $__timeFilter(s.interval_time)\nGROUP BY 1, 2\nORDER BY 1",
"refId": "A"
}
],
"title": "Update Rate by Peer (30-min buckets)",
"type": "timeseries"
}
],
"schemaVersion": 36,
"style": "dark",
"tags": ["obmp","learning","bgp","churn"],
"time": {"from": "now-24h","to": "now"},
"timepicker": {},
"timezone": "browser",
"title": "BGP Update Rate & Churn",
"uid": "obmp-learn-01",
"version": 1
}

View File

@ -123,3 +123,14 @@ providers:
path: /var/lib/grafana/dashboards/obmp/L3VPN-1005
# <bool> use folder names from filesystem to create folders in Grafana
foldersFromFilesStructure: false
- name: 'OpenBMP-Learning'
orgId: 1
folder: 'OBMP-Learning'
folderUid: '2001'
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards/Learning
foldersFromFilesStructure: false