Network: consolidate into one tabbed hub; subnet devices span all asset types
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s

Replaces the two flat "Network Devices" + "View Networks" nav entries with a
single "Network" entry opening a tabbed hub: Devices | Networks | VLANs
(NetworkHub renders the existing device list, the subnet browse, and the VLAN
list; VLANs is now reachable outside Settings). /network -> hub; /networks
redirects to the Networks tab; subnet detail stays at /networks/:id.

Subnet "Devices on this network" now matches ANY asset whose primary IP falls in
the CIDR (PCs, printers, machines, measuring tools - not just network devices),
computed on the core Communication + Asset tables; each row links to its typed
detail (extension id resolved lazily/guarded per plugin). Fixes the empty list -
printers and PCs carry IPs and now appear (e.g. 35 devices on 10.80.92.0/24).

Also: subnet-browse search uses the standard form-control styling; dropped the
redundant per-tab page header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-13 14:15:51 -04:00
parent dd541fba0a
commit 8528617037
7 changed files with 129 additions and 38 deletions

View File

@@ -831,31 +831,34 @@ def get_subnet(subnet_id: int):
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."""
"""ANY asset (PC, printer, network device, ...) whose primary IP falls in
cidr. A subnet is cross-type, so this matches on the core Communication +
Asset tables, not just network devices. Matching is in Python because the IP
lives in a Communication row, not a column."""
import ipaddress
from shopdb.api import Asset, AssetType, Communication
try:
net = ipaddress.ip_network(cidr, strict=False)
except (ValueError, TypeError):
return []
rows = (Communication.query
.join(Asset, Communication.assetid == Asset.assetid)
.join(AssetType, Asset.assettypeid == AssetType.assettypeid)
.filter(Communication.isprimary == True,
Communication.ipaddress.isnot(None))
.with_entities(Asset.assetid, Asset.assetnumber, Asset.name,
AssetType.assettype, Communication.ipaddress).all())
result = []
for netdev in NetworkDevice.query.all():
ip = _primary_ip(netdev.assetid)
for assetid, assetnumber, name, assettype, ip in rows:
ip = (ip or '').strip()
if not ip:
continue
try:
if ipaddress.ip_address(ip.strip()) in net:
asset = netdev.asset
if ipaddress.ip_address(ip) in net:
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),
'assetid': assetid, 'assetnumber': assetnumber, 'name': name,
'assettype': assettype, 'ipaddress': ip,
'url': _asset_detail_url(assettype, assetid),
})
except (ValueError, TypeError):
continue
@@ -863,6 +866,27 @@ def _devices_in_subnet(cidr):
return result
def _asset_detail_url(assettype, assetid):
"""Best-effort front-end detail path for an asset by type. Resolves the
per-type extension id lazily/guarded (subnet listings span plugins); returns
None when the plugin is absent so the frontend just shows the row."""
try:
if assettype == 'network_device':
row = NetworkDevice.query.filter_by(assetid=assetid).first()
return f'/network/{row.networkdeviceid}' if row else None
if assettype == 'printer':
from plugins.printers.models import Printer
row = Printer.query.filter_by(assetid=assetid).first()
return f'/printers/{row.printerid}' if row else None
if assettype == 'computer':
from plugins.computers.models import Computer
row = Computer.query.filter_by(assetid=assetid).first()
return f'/pcs/{row.computerid}' if row else None
except ImportError:
return None
return None
@network_bp.route('/subnets', methods=['POST'])
@jwt_required()
@require_permission('network.create')

View File

@@ -208,17 +208,11 @@ class NetworkPlugin(BasePlugin):
"""Return navigation menu items."""
return [
{
'name': 'Network Devices',
'name': 'Network',
'icon': 'network-wired',
'route': '/network',
'position': 18,
},
{
'name': 'View Networks',
'icon': 'globe',
'route': '/networks',
'position': 19,
},
]
def get_permissions(self) -> List: