obmp-docker/exabgp-ui/src/components/ChurnControl.vue
sam 6621942032 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>
2026-03-05 15:37:16 -07:00

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 ? '&#8734; (infinite)' : cycleCount }}</span></label>
<input
v-model.number="cycleCount"
type="range"
min="0"
max="20"
step="1"
:disabled="running"
class="slider"
/>
<div class="slider-labels">
<span>0 (&#8734;)</span>
<span>5</span>
<span>10</span>
<span>15</span>
<span>20</span>
</div>
</div>
<!-- Announce duration -->
<div class="form-group">
<label>Announce Duration: <span class="slider-val">{{ announceSecs }}s</span></label>
<input
v-model.number="announceSecs"
type="range"
min="5"
max="120"
step="5"
:disabled="running"
class="slider"
/>
<div class="slider-labels">
<span>5s</span>
<span>30s</span>
<span>60s</span>
<span>90s</span>
<span>120s</span>
</div>
</div>
<!-- Withdraw duration -->
<div class="form-group">
<label>Withdraw Duration: <span class="slider-val">{{ withdrawSecs }}s</span></label>
<input
v-model.number="withdrawSecs"
type="range"
min="5"
max="120"
step="5"
:disabled="running"
class="slider"
/>
<div class="slider-labels">
<span>5s</span>
<span>30s</span>
<span>60s</span>
<span>90s</span>
<span>120s</span>
</div>
</div>
<!-- Action buttons -->
<div class="action-row">
<button v-if="!running" class="btn-start" @click="startChurn" :disabled="!scenarioName.trim()">
<span>&#9654;</span> Start Churn
</button>
<button v-else class="btn-stop" @click="stopChurn">
<span>&#9632;</span> Stop
</button>
</div>
</div>
<!-- Status display -->
<div v-if="running || statusMsg" class="status-card">
<div class="status-header">
<span class="status-dot" :class="running ? 'dot-active' : 'dot-idle'"></span>
<span class="status-text">{{ statusMsg || 'Idle' }}</span>
</div>
<!-- Progress bar (only when cycling with fixed count) -->
<div v-if="running && cycleCount > 0" class="progress-section">
<div class="progress-labels">
<span>Cycle {{ currentCycle }} / {{ cycleCount }}</span>
<span>{{ progressPct }}%</span>
</div>
<div class="progress-track">
<div class="progress-fill" :style="{ width: progressPct + '%' }"></div>
</div>
</div>
<!-- Phase progress bar -->
<div v-if="running && phaseTotal > 0" class="progress-section">
<div class="progress-labels">
<span>{{ phaseLabel }} {{ phaseElapsed }}s / {{ phaseTotal }}s</span>
<span>{{ phasePct }}%</span>
</div>
<div class="progress-track">
<div class="progress-fill phase-fill" :style="{ width: phasePct + '%' }"></div>
</div>
</div>
<!-- Error -->
<div v-if="churnError" class="churn-error">{{ churnError }}</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import { api } from '../api.js'
const scenarioName = ref('churn')
const cycleCount = ref(5)
const announceSecs = ref(30)
const withdrawSecs = ref(15)
const running = ref(false)
const currentCycle = ref(0)
const statusMsg = ref('')
const churnError = ref(null)
const phaseLabel = ref('')
const phaseElapsed = ref(0)
const phaseTotal = ref(0)
let stopFlag = false
let phaseTimer = null
const progressPct = computed(() => {
if (!cycleCount.value || currentCycle.value === 0) return 0
return Math.round(((currentCycle.value - 1) / cycleCount.value) * 100)
})
const phasePct = computed(() => {
if (!phaseTotal.value) return 0
return Math.min(100, Math.round((phaseElapsed.value / phaseTotal.value) * 100))
})
function sleep(ms) {
return new Promise(resolve => {
const start = Date.now()
const tick = () => {
if (stopFlag) return resolve()
const elapsed = (Date.now() - start) / 1000
phaseElapsed.value = Math.round(elapsed)
if (Date.now() - start >= ms) {
resolve()
} else {
phaseTimer = setTimeout(tick, 500)
}
}
tick()
})
}
async function startChurn() {
if (!scenarioName.value.trim()) return
running.value = true
stopFlag = false
currentCycle.value = 0
churnError.value = null
statusMsg.value = 'Starting...'
const maxCycles = cycleCount.value === 0 ? Infinity : cycleCount.value
try {
for (let i = 1; i <= maxCycles; i++) {
if (stopFlag) break
currentCycle.value = i
statusMsg.value = `Cycle ${cycleCount.value > 0 ? i + '/' + cycleCount.value : i} — Announcing routes...`
phaseLabel.value = 'Announcing'
phaseTotal.value = announceSecs.value
phaseElapsed.value = 0
try {
await api.loadScenario(scenarioName.value.trim())
} catch (e) {
churnError.value = `Load failed: ${e.message}`
}
await sleep(announceSecs.value * 1000)
if (stopFlag) break
statusMsg.value = `Cycle ${cycleCount.value > 0 ? i + '/' + cycleCount.value : i} — Withdrawing routes...`
phaseLabel.value = 'Withdrawing'
phaseTotal.value = withdrawSecs.value
phaseElapsed.value = 0
try {
await api.unloadScenario(scenarioName.value.trim())
} catch (e) {
churnError.value = `Unload failed: ${e.message}`
}
await sleep(withdrawSecs.value * 1000)
}
} catch (e) {
churnError.value = `Churn error: ${e.message}`
} finally {
running.value = false
phaseTotal.value = 0
phaseElapsed.value = 0
if (!stopFlag) {
statusMsg.value = `Churn complete — ${currentCycle.value} cycle(s) finished`
} else {
statusMsg.value = `Stopped after ${currentCycle.value} cycle(s)`
}
}
}
function stopChurn() {
stopFlag = true
if (phaseTimer) clearTimeout(phaseTimer)
statusMsg.value = 'Stopping...'
}
onUnmounted(() => {
stopFlag = true
if (phaseTimer) clearTimeout(phaseTimer)
})
</script>
<style scoped>
.churn-control {
display: flex;
flex-direction: column;
gap: 18px;
max-width: 680px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.section-desc {
color: var(--muted);
font-size: 13px;
line-height: 1.6;
margin-top: -8px;
}
.config-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
display: flex;
flex-direction: column;
gap: 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.slider-val {
color: var(--accent);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
text-transform: none;
letter-spacing: 0;
}
.mono-input {
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 10px;
font-size: 13px;
outline: none;
max-width: 300px;
}
.mono-input:focus {
border-color: var(--accent);
}
.slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 5px;
border-radius: 3px;
background: var(--border);
outline: none;
cursor: pointer;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: none;
}
.slider:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.slider-labels {
display: flex;
justify-content: space-between;
font-size: 10px;
color: #4a5568;
padding: 0 2px;
}
.action-row {
display: flex;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--border);
}
.btn-start {
padding: 9px 22px;
background: rgba(72, 187, 120, 0.15);
color: #48bb78;
border: 1px solid rgba(72, 187, 120, 0.3);
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
gap: 7px;
}
.btn-start:hover:not(:disabled) {
background: rgba(72, 187, 120, 0.25);
}
.btn-stop {
padding: 9px 22px;
background: rgba(252, 129, 129, 0.15);
color: #fc8181;
border: 1px solid rgba(252, 129, 129, 0.3);
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
gap: 7px;
animation: pulse-border 1.5s ease-in-out infinite;
}
.btn-stop:hover {
background: rgba(252, 129, 129, 0.25);
}
.status-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
.status-header {
display: flex;
align-items: center;
gap: 10px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-active {
background: #48bb78;
box-shadow: 0 0 8px #48bb78;
animation: pulse-dot 1s ease-in-out infinite;
}
.dot-idle {
background: var(--muted);
}
.status-text {
font-size: 14px;
font-weight: 600;
color: var(--text);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
}
.progress-section {
display: flex;
flex-direction: column;
gap: 5px;
}
.progress-labels {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--muted);
}
.progress-track {
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.5s ease;
}
.phase-fill {
background: #48bb78;
}
.churn-error {
font-size: 12px;
color: #fc8181;
padding: 6px 10px;
background: rgba(252, 129, 129, 0.08);
border-radius: 4px;
border: 1px solid rgba(252, 129, 129, 0.2);
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes pulse-border {
0%, 100% { border-color: rgba(252, 129, 129, 0.3); }
50% { border-color: rgba(252, 129, 129, 0.6); }
}
</style>