- 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>
482 lines
11 KiB
Vue
482 lines
11 KiB
Vue
<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>
|