sam dcebf15bb3 Add Phase 4: gNMI streaming telemetry and traffic generator
- gNMI integration: NETCONF script to enable gRPC on all 9 routers,
  Telegraf container with gnmi input plugin, InfluxDB for time-series
  storage, 3 Grafana telemetry dashboards (utilization, errors, combined)
- Traffic generator: Scapy-based dual-mode container (sender/responder)
  with Flask API, RFC 2544 test suite (throughput, latency, frame-loss,
  back-to-back), Vue 3 web UI with flow builder, test runner, real-time
  stats monitor, and results export
- docker-compose.yml updated with influxdb, telegraf, traffic-gen,
  traffic-gen-ui services
- Full documentation in DOCS.md sections 15-16

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:29:44 -07:00

93 lines
3.4 KiB
Vue

<template>
<div class="flow-table">
<div v-if="!flows.length" class="empty">No flows created yet. Use the builder to create one.</div>
<table v-else>
<thead>
<tr>
<th>Name</th>
<th>Dst IP</th>
<th>Proto</th>
<th>Size</th>
<th>Rate</th>
<th>State</th>
<th>TX pps</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="f in flows" :key="f.id" :class="{ running: f.state === 'running' }">
<td>{{ f.name || '-' }}</td>
<td class="mono">{{ f.dst_ip }}</td>
<td>{{ f.protocol.toUpperCase() }}</td>
<td>{{ f.frame_size }}B</td>
<td>{{ f.rate_pps }} pps</td>
<td>
<span class="state-badge" :class="'state-' + f.state">{{ f.state }}</span>
</td>
<td class="mono">{{ stats[f.id]?.tx_pps || 0 }}</td>
<td class="actions">
<button v-if="f.state !== 'running'" class="btn-sm btn-go" @click="start(f.id)">Start</button>
<button v-else class="btn-sm btn-stop" @click="stop(f.id)">Stop</button>
<button class="btn-sm btn-del" @click="del(f.id)" :disabled="f.state === 'running'">Del</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { api } from '../api.js'
const props = defineProps({ flows: Array })
const emit = defineEmits(['refresh'])
const stats = ref({})
let statsTimer = null
async function fetchStats() {
for (const f of (props.flows || [])) {
if (f.state === 'running') {
try {
const s = await api.flowStats(f.id)
stats.value[f.id] = s
} catch (_) {}
}
}
}
onMounted(() => { statsTimer = setInterval(fetchStats, 1000) })
onUnmounted(() => { clearInterval(statsTimer) })
async function start(id) {
try { await api.startFlow(id); emit('refresh') } catch (e) { alert(e.message) }
}
async function stop(id) {
try { await api.stopFlow(id); emit('refresh') } catch (e) { alert(e.message) }
}
async function del(id) {
try { await api.deleteFlow(id); emit('refresh') } catch (e) { alert(e.message) }
}
</script>
<style scoped>
.flow-table { overflow-x: auto; }
.empty { color: var(--muted); padding: 20px; text-align: center; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; padding: 6px 8px; border-bottom: 1px solid var(--border); }
td { padding: 8px; border-bottom: 1px solid rgba(45,55,72,0.5); font-size: 13px; }
tr.running { background: rgba(79,156,249,0.05); }
.mono { font-family: 'Cascadia Code', 'Fira Code', monospace; font-size: 12px; }
.state-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 600; }
.state-idle { background: rgba(113,128,150,0.2); color: var(--muted); }
.state-running { background: rgba(72,187,120,0.15); color: var(--success); }
.state-stopped { background: rgba(246,173,85,0.15); color: var(--warning); }
.actions { display: flex; gap: 4px; }
.btn-sm { padding: 3px 10px; font-size: 11px; font-weight: 600; border-radius: 6px; }
.btn-go { background: var(--success); color: #fff; }
.btn-stop { background: var(--warning); color: #000; }
.btn-del { background: rgba(252,129,129,0.15); color: var(--danger); }
.btn-del:disabled { opacity: 0.3; }
</style>