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:
parent
233dadbb41
commit
6621942032
94
DOCS.md
94
DOCS.md
@ -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
|
||||
|
||||
|
||||
@ -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
12
exabgp-ui/Dockerfile
Normal 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
16
exabgp-ui/index.html
Normal 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
15
exabgp-ui/nginx.conf
Normal 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
17
exabgp-ui/package.json
Normal 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
301
exabgp-ui/src/App.vue
Normal file
@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<!-- HEADER -->
|
||||
<header class="app-header">
|
||||
<div class="header-title">
|
||||
<span class="logo-icon">◯</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">⚠</span>
|
||||
API unreachable: {{ apiError }} — 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
21
exabgp-ui/src/api.js
Normal 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'),
|
||||
}
|
||||
383
exabgp-ui/src/components/AnnounceForm.vue
Normal file
383
exabgp-ui/src/components/AnnounceForm.vue
Normal 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>▶</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>
|
||||
481
exabgp-ui/src/components/ChurnControl.vue
Normal file
481
exabgp-ui/src/components/ChurnControl.vue
Normal 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 ? '∞ (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 (∞)</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>▶</span> Start Churn
|
||||
</button>
|
||||
<button v-else class="btn-stop" @click="stopChurn">
|
||||
<span>■</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>
|
||||
214
exabgp-ui/src/components/PeerStatus.vue
Normal file
214
exabgp-ui/src/components/PeerStatus.vue
Normal 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>
|
||||
362
exabgp-ui/src/components/RouteTable.vue
Normal file
362
exabgp-ui/src/components/RouteTable.vue
Normal 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>✕</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">«</button>
|
||||
<button @click="page--" :disabled="page === 1" class="pg-btn">‹</button>
|
||||
<span class="pg-info">Page {{ page }} / {{ totalPages }}</span>
|
||||
<button @click="page++" :disabled="page === totalPages" class="pg-btn">›</button>
|
||||
<button @click="page = totalPages" :disabled="page === totalPages" class="pg-btn">»</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>
|
||||
319
exabgp-ui/src/components/ScenarioPanel.vue
Normal file
319
exabgp-ui/src/components/ScenarioPanel.vue
Normal 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 }">↻</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>▶</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>■</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>
|
||||
127
exabgp-ui/src/components/StatusBar.vue
Normal file
127
exabgp-ui/src/components/StatusBar.vue
Normal 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
3
exabgp-ui/src/main.js
Normal 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
14
exabgp-ui/vite.config.js
Normal 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/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
160
obmp-grafana/dashboards/Learning/learning_as_path.json
Normal file
160
obmp-grafana/dashboards/Learning/learning_as_path.json
Normal 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
|
||||
}
|
||||
201
obmp-grafana/dashboards/Learning/learning_attributes.json
Normal file
201
obmp-grafana/dashboards/Learning/learning_attributes.json
Normal 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
|
||||
}
|
||||
152
obmp-grafana/dashboards/Learning/learning_churn.json
Normal file
152
obmp-grafana/dashboards/Learning/learning_churn.json
Normal 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
|
||||
}
|
||||
144
obmp-grafana/dashboards/Learning/learning_peer_health.json
Normal file
144
obmp-grafana/dashboards/Learning/learning_peer_health.json
Normal 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
|
||||
}
|
||||
150
obmp-grafana/dashboards/Learning/learning_rpki.json
Normal file
150
obmp-grafana/dashboards/Learning/learning_rpki.json
Normal 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
|
||||
}
|
||||
137
obmp-grafana/dashboards/Learning/learning_update_rate.json
Normal file
137
obmp-grafana/dashboards/Learning/learning_update_rate.json
Normal 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
|
||||
}
|
||||
@ -122,4 +122,15 @@ providers:
|
||||
# <string, required> path to dashboard files on disk. Required when using the 'file' type
|
||||
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
|
||||
Loading…
x
Reference in New Issue
Block a user