From dd541fba0acf78f0e08d2a2e8eb4206032efa00b Mon Sep 17 00:00:00 2001 From: cproudlock Date: Mon, 13 Jul 2026 14:06:47 -0400 Subject: [PATCH] Add "View Networks": front-facing subnet browse + detail with attached devices Subnets previously lived only under Settings, easily confused with the Network Devices asset list. Add a front-facing browse + detail: - Nav: rename "Network" -> "Network Devices"; add "View Networks" (subnets), both under Assets (network plugin get_navigation_items; frontend fallback matched). - /networks (SubnetsBrowse): all subnets with name / CIDR / type / VLAN / notes, searchable, row-click to detail. - /networks/:id (SubnetDetail): the subnet (CIDR, network address, type, VLAN, gateway, notes) plus the network devices whose primary IP falls inside its CIDR - get_subnet now computes that membership in Python (a device's IP lives in a Communication row, so it is not a plain SQL join). Verified on the import DB: 37 networks list (real WJ subnets), detail renders. Co-Authored-By: Claude Opus 4.8 --- frontend/src/router/routes/network.js | 12 +++ frontend/src/views/AppLayout.vue | 3 +- frontend/src/views/network/SubnetDetail.vue | 81 ++++++++++++++++++++ frontend/src/views/network/SubnetsBrowse.vue | 79 +++++++++++++++++++ plugins/network/api/routes.py | 40 +++++++++- plugins/network/plugin.py | 8 +- 6 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 frontend/src/views/network/SubnetDetail.vue create mode 100644 frontend/src/views/network/SubnetsBrowse.vue diff --git a/frontend/src/router/routes/network.js b/frontend/src/router/routes/network.js index 3af6d73..a7eea5f 100644 --- a/frontend/src/router/routes/network.js +++ b/frontend/src/router/routes/network.js @@ -26,6 +26,18 @@ export default [ component: () => import('../../views/network/NetworkDeviceForm.vue'), meta: { requiresAuth: true, plugin: 'network' } }, + { + path: 'networks', + name: 'networks', + component: () => import('../../views/network/SubnetsBrowse.vue'), + meta: { plugin: 'network' } + }, + { + path: 'networks/:id', + name: 'network-subnet-detail', + component: () => import('../../views/network/SubnetDetail.vue'), + meta: { plugin: 'network' } + }, // Network-specific settings { path: 'settings/vlans', diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index e101527..a0427b6 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -156,7 +156,8 @@ const defaultNav = [ { name: 'Map', icon: 'map', route: '/map', position: 4 }, { name: 'Machines', icon: 'cog', route: '/machines', position: 10 }, { name: 'PCs', icon: 'desktop', route: '/pcs', position: 15 }, - { name: 'Network', icon: 'network-wired', route: '/network', position: 18 }, + { name: 'Network Devices', icon: 'network-wired', route: '/network', position: 18 }, + { name: 'View Networks', icon: 'globe', route: '/networks', position: 19 }, { name: 'Printers', icon: 'printer', route: '/printers', position: 20 }, { name: 'USB Devices', icon: 'usb', route: '/usb', position: 45 }, { name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information' }, diff --git a/frontend/src/views/network/SubnetDetail.vue b/frontend/src/views/network/SubnetDetail.vue new file mode 100644 index 0000000..e317b53 --- /dev/null +++ b/frontend/src/views/network/SubnetDetail.vue @@ -0,0 +1,81 @@ + + + diff --git a/frontend/src/views/network/SubnetsBrowse.vue b/frontend/src/views/network/SubnetsBrowse.vue new file mode 100644 index 0000000..562e6eb --- /dev/null +++ b/frontend/src/views/network/SubnetsBrowse.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py index 06657cb..7f09f14 100644 --- a/plugins/network/api/routes.py +++ b/plugins/network/api/routes.py @@ -814,7 +814,8 @@ def list_subnets(): @network_bp.route('/subnets/', methods=['GET']) @jwt_required(optional=True) def get_subnet(subnet_id: int): - """Get a single subnet.""" + """Get a single subnet plus the network devices whose primary IP falls in + its CIDR range.""" subnet = db.session.get(Subnet, subnet_id) if not subnet: @@ -824,7 +825,42 @@ def get_subnet(subnet_id: int): http_code=404 ) - return success_response(subnet.to_dict()) + data = subnet.to_dict() + data['devices'] = _devices_in_subnet(subnet.cidr) + return success_response(data) + + +def _devices_in_subnet(cidr): + """Network devices (with their primary IP) whose IP is inside cidr. Matching + is done in Python because a device's IP lives in a Communication row, not a + column, so it cannot be a simple SQL join.""" + import ipaddress + try: + net = ipaddress.ip_network(cidr, strict=False) + except (ValueError, TypeError): + return [] + result = [] + for netdev in NetworkDevice.query.all(): + ip = _primary_ip(netdev.assetid) + if not ip: + continue + try: + if ipaddress.ip_address(ip.strip()) in net: + asset = netdev.asset + result.append({ + 'networkdeviceid': netdev.networkdeviceid, + 'assetid': netdev.assetid, + 'assetnumber': asset.assetnumber if asset else None, + 'name': asset.name if asset else None, + 'hostname': netdev.hostname, + 'ipaddress': ip, + 'networkdevicetypename': (netdev.networkdevicetype.networkdevicetype + if netdev.networkdevicetype else None), + }) + except (ValueError, TypeError): + continue + result.sort(key=lambda d: d['ipaddress']) + return result @network_bp.route('/subnets', methods=['POST']) diff --git a/plugins/network/plugin.py b/plugins/network/plugin.py index 903129c..3b29c64 100644 --- a/plugins/network/plugin.py +++ b/plugins/network/plugin.py @@ -208,11 +208,17 @@ class NetworkPlugin(BasePlugin): """Return navigation menu items.""" return [ { - 'name': 'Network', + 'name': 'Network Devices', 'icon': 'network-wired', 'route': '/network', 'position': 18, }, + { + 'name': 'View Networks', + 'icon': 'globe', + 'route': '/networks', + 'position': 19, + }, ] def get_permissions(self) -> List: