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