Merge pull request #595 from jcastro/feature/monitor-ovs-topology

feat(monitor): show Open vSwitch network topology
This commit is contained in:
MacRimi
2026-11-05 18:46:54 +01:00
committed by GitHub
8 changed files with 391 additions and 11 deletions
+30
View File
@@ -32,6 +32,11 @@ on:
- 'AppImage/scripts/flask_proxmenux_routes.py'
- 'AppImage/scripts/guest_backup_archives.py'
- 'AppImage/scripts/flask_server.py'
- 'AppImage/scripts/ovs_topology.py'
- 'AppImage/scripts/tests/test_ovs_topology.py'
- 'AppImage/components/network-flow.tsx'
- 'AppImage/components/network-metrics.tsx'
- 'AppImage/tests/ovs-network-flow.cjs'
- 'AppImage/scripts/jwt_middleware.py'
- 'AppImage/scripts/build_appimage.sh'
- 'AppImage/components/guest-backup-list.tsx'
@@ -93,6 +98,11 @@ on:
- 'AppImage/scripts/flask_proxmenux_routes.py'
- 'AppImage/scripts/guest_backup_archives.py'
- 'AppImage/scripts/flask_server.py'
- 'AppImage/scripts/ovs_topology.py'
- 'AppImage/scripts/tests/test_ovs_topology.py'
- 'AppImage/components/network-flow.tsx'
- 'AppImage/components/network-metrics.tsx'
- 'AppImage/tests/ovs-network-flow.cjs'
- 'AppImage/scripts/jwt_middleware.py'
- 'AppImage/components/guest-backup-list.tsx'
- 'AppImage/components/virtual-machines.tsx'
@@ -435,3 +445,23 @@ jobs:
persist-credentials: false
- name: Exercise private images with isolated mapped Linux readers
run: sudo /usr/bin/python3 -B -m unittest discover -s oci/tests -p test_native_import_access.py -v
network-topology:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v6
with:
node-version: '22.14.0'
- name: Test read-only OVS discovery and mixed network API
run: python -B -m unittest discover -s AppImage/scripts/tests -p test_ovs_topology.py -v
- name: Test both Network Flow layouts
run: |
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/ovs-network-flow.cjs
+4 -1
View File
@@ -62,7 +62,7 @@ type Bridge = {
type Guest = {
id: string
label: string
kind: "lxc" | "vm" | "host"
kind: "lxc" | "vm" | "host" | "interface"
bridge: string
rx: number
tx: number
@@ -87,6 +87,8 @@ const ICONS: Record<string, string> = {
vm: `<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>`,
}
ICONS.interface = ICONS.bridge
const COLORS = {
host: "var(--amber-500, #f59e0b)",
lxc: "var(--cyan-500, #06b6d4)",
@@ -97,6 +99,7 @@ const COLORS = {
// just another NIC.
bond: "var(--orange-500, #f97316)",
bridge: "var(--cyan-500, #06b6d4)",
interface: "var(--cyan-500, #06b6d4)",
gray: "#525252",
}
+12 -4
View File
@@ -101,6 +101,9 @@ interface NetworkInterface {
// Master device resolved from /sys/class/net/<iface>/master — the
// bridge for a guest tap, the bond for a slave NIC.
bridge_owner?: string
network_backend?: "ovs"
ovs_tag?: number | null
ovs_trunks?: number[]
bridge_members?: string[]
bridge_physical_interface?: string
bridge_bond_slaves?: string[]
@@ -568,6 +571,7 @@ export function NetworkMetrics() {
...(networkData.bond_interfaces || []),
...(networkData.bridge_interfaces || []),
...(networkData.vm_lxc_interfaces || []),
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
]
const vmLxcInterfaces = (networkData.vm_lxc_interfaces || []).sort((a, b) => {
@@ -867,6 +871,7 @@ export function NetworkMetrics() {
...(networkData.bond_interfaces || []),
...(networkData.bridge_interfaces || []),
...(networkData.vm_lxc_interfaces || []),
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
]
const flowData: NetworkFlowData = {
nics: (networkData.physical_interfaces || []).map((p) => ({
@@ -916,7 +921,9 @@ export function NetworkMetrics() {
tx: runningGuests.reduce((a, v) => a + toMBps(v.tx_Bps), 0),
}
})(),
...(networkData.vm_lxc_interfaces || []).map((v) => {
...[...(networkData.vm_lxc_interfaces || []),
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
].map((v) => {
// Authoritative bridge from the kernel (read by the
// backend from /sys/class/net/<iface>/master). Fallback
// to bridge_members scan, then first bridge as last
@@ -929,8 +936,8 @@ export function NetworkMetrics() {
(networkData.bridge_interfaces?.[0]?.name || "")
return {
id: v.name,
label: v.vm_name || v.name,
kind: (v.vm_type === "vm" ? "vm" : "lxc") as "vm" | "lxc",
label: v.vm_name || (v.ovs_tag != null ? `${v.name} · VLAN ${v.ovs_tag}` : v.name),
kind: v.type === "vlan" ? "interface" as const : (v.vm_type === "vm" ? "vm" as const : "lxc" as const),
bridge: ownerName,
rx: toMBps(v.rx_Bps),
tx: toMBps(v.tx_Bps),
@@ -1213,6 +1220,7 @@ export function NetworkMetrics() {
...(modalNetworkData.bond_interfaces || []),
...(modalNetworkData.bridge_interfaces || []),
...(modalNetworkData.vm_lxc_interfaces || []),
...(modalNetworkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
].find((iface) => iface.name === selectedInterface.name)
: selectedInterface
@@ -1298,7 +1306,7 @@ export function NetworkMetrics() {
</div>
<div>
<div className="text-sm text-muted-foreground">MTU</div>
<div className="font-medium">{displayInterface.mtu}</div>
<div className="font-medium">{displayInterface.mtu || t("common.notAvailable")}</div>
</div>
{displayInterface.mac_address && (
<div className="col-span-2">
+1
View File
@@ -140,6 +140,7 @@ cd "$SCRIPT_DIR"
# Copy Flask server
echo "📋 Copying Flask server..."
cp "$SCRIPT_DIR/flask_server.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/ovs_topology.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/zram_metrics.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/flask_auth_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_auth_routes.py not found"
cp "$SCRIPT_DIR/auth_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ auth_manager.py not found"
+48 -6
View File
@@ -53,6 +53,7 @@ import threading
import urllib.parse
import uuid
import disk_identity
import ovs_topology
import hardware_monitor
from health_persistence import health_persistence
import xml.etree.ElementTree as ET
@@ -6258,17 +6259,24 @@ def _iface_master(iface_name):
# Chain: veth/tap → fwbr (intermediate) → fwpr (peer) → vmbr (real).
def _resolve_bridge_owner(iface_name):
import re
ovs_owners = ovs_topology.get_topology().get('owners', {})
if iface_name in ovs_owners:
return ovs_owners[iface_name]
master = _iface_master(iface_name)
if not master:
return None
if master.startswith('fwbr'):
fwpr = re.sub(r'^fwbr', 'fwpr', master)
fwpr = re.sub(r'i(\d+)$', r'p\1', fwpr)
# A Linux bridge reaches the firewall bridge through fwprNpM; an OVS
# bridge through its own internal port fwlnNoM.
suffix = master[len('fwbr'):]
links = (re.sub(r'i(\d+)$', r'p\1', 'fwpr' + suffix),
re.sub(r'i(\d+)$', r'o\1', 'fwln' + suffix))
try:
if os.path.exists(f'/sys/class/net/{fwpr}'):
real = _iface_master(fwpr)
if real:
return real
for link in links:
if os.path.exists(f'/sys/class/net/{link}'):
real = ovs_owners.get(link) or _iface_master(link)
if real:
return real
except Exception:
pass
return master
@@ -6351,6 +6359,10 @@ def get_interface_type(interface_name):
if interface_name.startswith(('tun', 'vnet', 'docker', 'virbr')):
return 'skip'
ovs_type = ovs_topology.get_topology().get('types', {}).get(interface_name)
if ovs_type:
return ovs_type
# Check if it's a bond — sysfs first (name-agnostic), then the
# "bondN" convention as fallback when /sys isn't readable.
if _is_bond_master(interface_name) or interface_name.startswith('bond'):
@@ -6590,6 +6602,7 @@ def get_network_info():
pass
vm_lxc_map = get_vm_lxc_names()
ovs = ovs_topology.get_topology()
# Get network interfaces
net_if_addrs = psutil.net_if_addrs()
@@ -6724,8 +6737,10 @@ def get_network_info():
# vmbr so a guest behind the Proxmox firewall still lands
# under its true bridge in the Network Flow diagram.
master = _resolve_bridge_owner(interface_name)
master = ovs.get('owners', {}).get(interface_name) or master
if master:
interface_info['bridge_owner'] = master
interface_info.update(ovs.get('ports', {}).get(interface_name, {}))
if interface_type == 'bond':
bond_info = get_bond_info(interface_name)
@@ -6738,6 +6753,12 @@ def get_network_info():
if interface_type == 'bridge':
bridge_info = get_bridge_info(interface_name)
if interface_name in ovs.get('bridges', {}):
bridge_info['members'] = ovs['bridges'][interface_name]
bridge_info['physical_interface'] = next((member for member in bridge_info['members']
if member in ovs.get('bonds', {}) or _is_physical_dev(member)), None)
bridge_info['bond_slaves'] = ovs.get('bonds', {}).get(bridge_info['physical_interface'], {}).get('bond_slaves', [])
interface_info['network_backend'] = 'ovs'
interface_info['bridge_members'] = bridge_info['members']
interface_info['bridge_physical_interface'] = bridge_info['physical_interface']
interface_info['bridge_physical_duplex'] = bridge_info['physical_duplex']
@@ -6758,6 +6779,22 @@ def get_network_info():
if interface_type == 'bond':
network_data['bond_interfaces'].append(interface_info)
# An OVS bond is an OVSDB port, not a kernel network interface.
for name, detail in ovs.get('bonds', {}).items():
slaves = [item for item in network_data['physical_interfaces']
if item['name'] in detail['bond_slaves']]
network_data['bond_interfaces'] = [item for item in network_data['bond_interfaces'] if item['name'] != name]
bond_interface = {
'name': name, 'type': 'bond', 'addresses': [],
'status': 'up' if any(item['status'] == 'up' for item in slaves) else 'down',
# OVSDB membership does not reveal active-backup capacity.
'speed': 0, 'duplex': 'unknown', 'mtu': 0, 'mac_address': None,
'rx_Bps': sum(item.get('rx_Bps', 0) for item in slaves),
'tx_Bps': sum(item.get('tx_Bps', 0) for item in slaves), **detail}
network_data['bond_interfaces'].append(bond_interface)
network_data['interfaces'] = [item for item in network_data['interfaces'] if item['name'] != name]
network_data['interfaces'].append(bond_interface)
network_data['physical_active_count'] = physical_active_count
network_data['physical_total_count'] = physical_total_count
network_data['bridge_active_count'] = bridge_active_count
@@ -6808,6 +6845,11 @@ def get_network_info():
bridges_per_nic.setdefault(slave, []).append(bridge['name'])
else:
bridges_per_nic.setdefault(phys, []).append(bridge['name'])
for name, members in ovs.get('bridges', {}).items():
for member in members:
for nic in ovs.get('bonds', {}).get(member, {}).get('bond_slaves', [member]):
if name not in bridges_per_nic.setdefault(nic, []):
bridges_per_nic[nic].append(name)
for phy in network_data['physical_interfaces']:
if phy['name'] in bridges_per_nic:
phy['used_by_bridges'] = bridges_per_nic[phy['name']]
+109
View File
@@ -0,0 +1,109 @@
"""Read-only OVSDB topology; Linux bridges continue to use their sysfs path.
OVS does not publish Linux bridge brif/master links, and an OVSBond has
no kernel netdev. Read the three OVSDB tables once per short cache window
instead of guessing from names or changing the host's network config.
"""
import json
import shutil
import subprocess
import threading
import time
_CACHE_SECONDS = 15
_lock = threading.Lock()
_cached_at = 0
_cached = {}
def _value(value):
if isinstance(value, list) and len(value) == 2:
if value[0] in ('uuid', 'named-uuid'):
return value[1]
if value[0] == 'set':
return [_value(item) for item in value[1]]
return value
def _rows(table):
return [{key: _value(value) for key, value in zip(table['headings'], row)}
for row in table['data']]
def _list(value):
return value if isinstance(value, list) else [] if value is None or value == '' else [value]
def parse_topology(bridges, ports, interfaces):
"""Normalize OVSDB UUID references, including singleton sets and bonds."""
iface_by_id = {row['_uuid']: row for row in _rows(interfaces)}
port_by_id = {row['_uuid']: row for row in _rows(ports)}
topology = {'types': {}, 'owners': {}, 'bridges': {}, 'bonds': {}, 'ports': {}}
for bridge in _rows(bridges):
name = bridge['name']
topology['types'][name] = 'bridge'
members = []
for port_id in _list(bridge.get('ports')):
port = port_by_id.get(port_id)
if not port:
continue
port_name = port['name']
ifaces = [iface_by_id[i] for i in _list(port.get('interfaces')) if i in iface_by_id]
# The bridge's own internal port is not a downstream member.
if port_name == name:
continue
members.append(port_name)
detail = {'bridge_owner': name, 'network_backend': 'ovs',
'ovs_tag': next(iter(_list(port.get('tag'))), None),
'ovs_trunks': _list(port.get('trunks'))}
topology['ports'][port_name] = detail
topology['owners'][port_name] = name
is_bond = len(ifaces) > 1
if is_bond:
topology['types'][port_name] = 'bond'
mode = port.get('bond_mode') or 'balance-slb'
topology['bonds'][port_name] = {
'bond_mode': mode, 'bond_slaves': [i['name'] for i in ifaces],
# OVSDB alone does not tell us which slave is active.
'bond_active_slave': None, 'bond_supports_failover': False,
'bond_slave_status': {}, **detail}
for iface in ifaces:
iface_name = iface['name']
topology['owners'][iface_name] = name
topology['ports'][iface_name] = detail
if is_bond:
topology['ports'][iface_name] = {**detail, 'bond_master': port_name,
'bond_role': 'member'}
elif iface.get('type') == 'internal' and not iface_name.startswith(('fwpr', 'fwln')):
topology['types'][iface_name] = 'vlan'
topology['bridges'][name] = members
return topology
def get_topology():
"""Bounded, cached reads; missing OVS or an unavailable daemon is harmless."""
global _cached_at, _cached
with _lock:
now = time.monotonic()
if now - _cached_at < _CACHE_SECONDS:
return _cached
binary = shutil.which('ovs-vsctl')
result = {}
if binary:
try:
tables = []
for table, columns in (
('Bridge', '_uuid,name,ports'),
('Port', '_uuid,name,interfaces,bond_mode,tag,trunks'),
('Interface', '_uuid,name,type'),
):
proc = subprocess.run(
[binary, '--timeout=1', '--format=json', f'--columns={columns}', 'list', table],
capture_output=True, text=True, timeout=2, check=True)
tables.append(json.loads(proc.stdout))
result = parse_topology(*tables)
except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError):
# Do not keep stale ownership when OVS disappears or is reconfigured.
result = {}
_cached, _cached_at = result, now
return _cached
+148
View File
@@ -0,0 +1,148 @@
"""OVSDB fixtures exercise live ownership, UUID sets, bonds and API output."""
import ast
import importlib.util
import json
import os
from pathlib import Path
import socket
import subprocess
import types
import unittest
from unittest.mock import patch
SCRIPTS = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('ovs_topology_test', SCRIPTS / 'ovs_topology.py')
ovs = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ovs)
def uuid(name):
return ['uuid', name]
def refs(*names):
return ['set', [uuid(name) for name in names]]
def table(headings, *rows):
return {'headings': headings.split(','), 'data': list(rows)}
def fixture():
# Mixed Linux/OVS host, named bond without a kernel netdev, tagged
# internal port, direct guest and guest behind a Proxmox firewall,
# which on OVS links its fwbr bridge through the internal port fwlnNoM.
return (
table('_uuid,name,ports', ['b1', 'switch0', refs('p0', 'p1', 'p2', 'p3', 'p4', 'p5')]),
table('_uuid,name,interfaces,bond_mode,tag,trunks',
['p0', 'switch0', uuid('i0'), '', ['set', []], ['set', []]],
['p1', 'eno1', uuid('i1'), '', ['set', []], ['set', [10, 20]]],
['p2', 'uplink', refs('i2', 'i3'), 'balance-tcp', ['set', []], ['set', []]],
['p3', 'vlan100', uuid('i4'), '', 100, ['set', []]],
['p4', 'tap103i0', uuid('i5'), '', 100, ['set', []]],
['p5', 'fwln115o0', uuid('i6'), '', 10, ['set', []]]),
table('_uuid,name,type', ['i0', 'switch0', 'internal'], ['i1', 'eno1', ''],
['i2', 'eno2', ''], ['i3', 'eno3', ''], ['i4', 'vlan100', 'internal'],
['i5', 'tap103i0', ''], ['i6', 'fwln115o0', 'internal']))
class OvsTopology(unittest.TestCase):
def setUp(self):
ovs._cached_at = 0
ovs._cached = {}
self.addCleanup(setattr, ovs, '_cached_at', 0)
self.addCleanup(setattr, ovs, '_cached', {})
def test_ovsdb_singletons_bonds_tags_and_internal_ports(self):
data = ovs.parse_topology(*fixture())
self.assertEqual(data['types']['switch0'], 'bridge')
self.assertEqual(data['types']['vlan100'], 'vlan')
self.assertNotIn('switch0', data['bridges']['switch0'])
self.assertEqual(data['owners']['tap103i0'], 'switch0')
self.assertEqual(data['ports']['vlan100']['ovs_tag'], 100)
self.assertEqual(data['ports']['eno1']['ovs_trunks'], [10, 20])
self.assertEqual(data['bonds']['uplink']['bond_slaves'], ['eno2', 'eno3'])
self.assertIsNone(data['bonds']['uplink']['bond_active_slave'])
self.assertEqual(data['ports']['eno2']['bond_master'], 'uplink')
def test_zero_vlan_tag_is_preserved(self):
tables = fixture()
tables[1]['data'][3][4] = 0
self.assertEqual(ovs.parse_topology(*tables)['ports']['vlan100']['ovs_tag'], 0)
def test_no_ovs_does_not_spawn_any_process(self):
with patch.object(ovs.shutil, 'which', return_value=None), patch.object(ovs.subprocess, 'run') as run:
self.assertEqual(ovs.get_topology(), {})
run.assert_not_called()
def test_read_only_queries_cached_and_failure_drops_stale_topology(self):
replies = [types.SimpleNamespace(stdout=json.dumps(t)) for t in fixture()]
with patch.object(ovs.shutil, 'which', return_value='/usr/bin/ovs-vsctl'), \
patch.object(ovs.subprocess, 'run', side_effect=replies) as run:
first = ovs.get_topology()
self.assertEqual(ovs.get_topology(), first)
self.assertEqual(run.call_count, 3)
for call in run.call_args_list:
self.assertEqual(call.args[0][-2], 'list')
self.assertIn('--timeout=1', call.args[0])
ovs._cached_at = 0
with patch.object(ovs.shutil, 'which', return_value='/usr/bin/ovs-vsctl'), \
patch.object(ovs.subprocess, 'run', side_effect=subprocess.TimeoutExpired('ovs-vsctl', 2)):
self.assertEqual(ovs.get_topology(), {})
def test_malformed_or_reconfigured_database_is_harmless(self):
for payload in ('not-json', '{}', '{"headings": [], "data": [[]]}'):
ovs._cached_at = 0
with patch.object(ovs.shutil, 'which', return_value='/usr/bin/ovs-vsctl'), \
patch.object(ovs.subprocess, 'run', return_value=types.SimpleNamespace(stdout=payload)):
self.assertEqual(ovs.get_topology(), {})
def api_namespace(self):
names = {'get_network_info', 'get_interface_type', '_resolve_bridge_owner'}
tree = ast.parse((SCRIPTS / 'flask_server.py').read_text())
nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in names]
data = ovs.parse_topology(*fixture())
names = ['eno1', 'eno2', 'eno3', 'switch0', 'vlan100', 'tap103i0', 'tap115i0', 'vmbr1']
stats = {name: types.SimpleNamespace(isup=True, speed=1000, duplex=2, mtu=1500) for name in names}
counter = types.SimpleNamespace(bytes_sent=1000, bytes_recv=2000, packets_sent=10, packets_recv=20,
errin=0, errout=0, dropin=0, dropout=0)
ns = {'os': os, 'socket': socket,
'ovs_topology': types.SimpleNamespace(get_topology=lambda: data),
'get_proxmox_node_name': lambda: 'pve', 'get_vm_lxc_names': lambda: {},
'psutil': types.SimpleNamespace(net_if_addrs=lambda: dict.fromkeys(names, []),
net_if_stats=lambda: stats,
net_io_counters=lambda **kw: dict.fromkeys(names, counter) if kw else counter),
'_ensure_net_rate_sampler': lambda: None, '_compute_per_nic_rates_live': lambda _: {},
'_is_bond_master': lambda _: False, '_is_physical_dev': lambda name: name.startswith('eno'),
'_ethtool_max_speed': lambda _: 1000,
'_iface_master': lambda name: {'tap115i0': 'fwbr115i0', 'fwln115o0': 'ovs-system'}.get(name),
'extract_vmid_from_interface': lambda name: (103 if '103' in name else 115, 'vm'),
'get_bridge_info': lambda _: {'members': [], 'physical_interface': None,
'physical_duplex': 'unknown', 'bond_slaves': []}}
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'flask_server.py', 'exec'), ns)
return ns
def test_api_preserves_linux_bridge_and_assigns_ovs_guests_and_bond(self):
ns = self.api_namespace()
# OVS creates no fwpr peer for the firewall bridge.
with patch('os.path.exists', side_effect=lambda path: 'fwpr' not in str(path)):
result = ns['get_network_info']()
self.assertNotIn('error', result)
self.assertEqual({i['name'] for i in result['bridge_interfaces']}, {'switch0', 'vmbr1'})
self.assertEqual(result['bond_interfaces'][0]['name'], 'uplink')
self.assertEqual(result['bond_interfaces'][0]['speed'], 0)
self.assertIsNone(result['bond_interfaces'][0]['mac_address'])
self.assertIn('uplink', {i['name'] for i in result['interfaces']})
for guest in result['vm_lxc_interfaces']:
self.assertEqual(guest['bridge_owner'], 'switch0')
self.assertEqual(next(i for i in result['interfaces'] if i['name'] == 'vlan100')['ovs_tag'], 100)
for nic in result['physical_interfaces']:
self.assertIn('switch0', nic['used_by_bridges'])
def test_packaged_module_is_shipped_with_backend(self):
self.assertIn('cp "$SCRIPT_DIR/ovs_topology.py" "$APP_DIR/usr/bin/"',
(SCRIPTS / 'build_appimage.sh').read_text())
if __name__ == '__main__':
unittest.main()
+39
View File
@@ -0,0 +1,39 @@
// Exercise both shipped SVG layouts with OVS internal ports and bond topology.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const source = fs.readFileSync(path.join(__dirname, '../components/network-flow.tsx'), 'utf8')
const code = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
}).outputText
const sandbox = { exports: {}, require: () => ({}), console }
vm.runInNewContext(code, sandbox)
const data = {
nics: [
{ id: 'eno1', link: '1 Gb/s', rx: 1, tx: 2, status: 'up' },
{ id: 'eno2', link: '1 Gb/s', rx: 1, tx: 2, status: 'up', bond: 'uplink', role: 'member' },
{ id: 'eno3', link: '1 Gb/s', rx: 1, tx: 2, status: 'up', bond: 'uplink', role: 'member' },
],
bonds: [{ id: 'uplink', mode: 'balance-tcp', rx: 2, tx: 4, status: 'up' }],
bridges: [{ id: 'switch0', parent: 'eno1' }, { id: 'vmbr1' }],
consumers: [
{ id: 'host', kind: 'host', label: 'host', bridge: 'switch0', rx: 1, tx: 2 },
{ id: 'vlan100', kind: 'interface', label: 'vlan100 · VLAN 100', bridge: 'switch0', rx: 0, tx: 0 },
{ id: 'tap103i0', kind: 'vm', label: 'VM 103', bridge: 'switch0', rx: 1, tx: 2 },
{ id: 'veth115i0', kind: 'lxc', label: 'LXC 115', bridge: 'switch0', rx: 1, tx: 2 },
{ id: 'tap104i0', kind: 'vm', label: 'VM 104', bridge: 'vmbr1', rx: 1, tx: 2 },
],
}
const labels = { down: 'Down', active: 'Active', standby: 'Standby' }
for (const render of [sandbox.renderHorizontal, sandbox.renderVertical]) {
const result = render(data, 1500, labels, 'Bytes')
for (const id of ['switch0', 'vmbr1', 'vlan100', 'tap103i0', 'veth115i0', 'tap104i0', 'uplink']) {
assert.ok(result.svg.includes(`data-node-id="${id}"`), `layout must draw ${id}`)
}
assert.ok(result.svg.includes('vlan100 · VLAN 100'))
assert.ok(!result.svg.includes('NaN'))
assert.ok(!result.svg.includes('undefined'))
}
console.log('PASS: both Network Flow layouts render OVS bonds, internal VLAN ports and mixed Linux/OVS guests')