diff --git a/.github/workflows/test-features.yml b/.github/workflows/test-features.yml index 27349ac0..99d8e435 100644 --- a/.github/workflows/test-features.yml +++ b/.github/workflows/test-features.yml @@ -27,6 +27,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' @@ -86,6 +91,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' @@ -387,3 +397,23 @@ jobs: run: | npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/pbs-navigation.cjs + + 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 diff --git a/AppImage/components/network-flow.tsx b/AppImage/components/network-flow.tsx index 6da3ffae..401ce8cf 100644 --- a/AppImage/components/network-flow.tsx +++ b/AppImage/components/network-flow.tsx @@ -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 = { vm: ``, } +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", } diff --git a/AppImage/components/network-metrics.tsx b/AppImage/components/network-metrics.tsx index 1625d012..e1c64bf8 100644 --- a/AppImage/components/network-metrics.tsx +++ b/AppImage/components/network-metrics.tsx @@ -101,6 +101,9 @@ interface NetworkInterface { // Master device resolved from /sys/class/net//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//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() {
MTU
-
{displayInterface.mtu}
+
{displayInterface.mtu || t("common.notAvailable")}
{displayInterface.mac_address && (
diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index 3ccf3d66..db259e0f 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -126,6 +126,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" diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 3fea17d1..91fe3466 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -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,6 +6259,9 @@ 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 @@ -6266,7 +6270,7 @@ def _resolve_bridge_owner(iface_name): fwpr = re.sub(r'i(\d+)$', r'p\1', fwpr) try: if os.path.exists(f'/sys/class/net/{fwpr}'): - real = _iface_master(fwpr) + real = ovs_owners.get(fwpr) or _iface_master(fwpr) if real: return real except Exception: @@ -6351,6 +6355,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 +6598,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 +6733,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 +6749,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 +6775,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 +6841,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']] diff --git a/AppImage/scripts/ovs_topology.py b/AppImage/scripts/ovs_topology.py new file mode 100644 index 00000000..4d4792cd --- /dev/null +++ b/AppImage/scripts/ovs_topology.py @@ -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 [value] if value else [] + + +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 diff --git a/AppImage/scripts/tests/test_ovs_topology.py b/AppImage/scripts/tests/test_ovs_topology.py new file mode 100644 index 00000000..6a0061de --- /dev/null +++ b/AppImage/scripts/tests/test_ovs_topology.py @@ -0,0 +1,141 @@ +"""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. + 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', 'fwpr115p0', uuid('i6'), '', 10, ['set', []]]), + table('_uuid,name,type', ['i0', 'switch0', 'internal'], ['i1', 'eno1', ''], + ['i2', 'eno2', ''], ['i3', 'eno3', ''], ['i4', 'vlan100', 'internal'], + ['i5', 'tap103i0', ''], ['i6', 'fwpr115p0', '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_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', 'fwpr115p0': '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() + with patch('os.path.exists', return_value=True): + 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() diff --git a/AppImage/tests/ovs-network-flow.cjs b/AppImage/tests/ovs-network-flow.cjs new file mode 100644 index 00000000..d07ba79b --- /dev/null +++ b/AppImage/tests/ovs-network-flow.cjs @@ -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')