Skip to content

Commit 825312d

Browse files
Enhance UX for Device Discovery (#31)
* (feature): Autodiscovery now parses interface inventory from SNMP or SSH grabs into InventoryInterface model to be properly displayed on Inventory/Device CRUD modal. * (feature): Correct some issues with Inventory Discovery results page not installing properly and enhanced UX.
1 parent d7310a9 commit 825312d

File tree

8 files changed

+259
-161
lines changed

8 files changed

+259
-161
lines changed

src/network_ops_dashboard/inventory/discovery/scripts/services.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,66 @@ def parse_interfaces(raw_list):
167167
interfaces.append(entry.strip())
168168
return interfaces
169169

170+
def parse_interface_name(name):
171+
"""
172+
Parse interface names into (prefix, rack, slot, subslot, port).
173+
Supports Cisco (IOS, NX-OS, IOS-XR) and Juniper (ge-, xe-, et-, ae-)
174+
Examples:
175+
Cisco:
176+
Ethernet2/43 -> slot=2, port=43
177+
GigabitEthernet1/2/3 -> slot=1, subslot=2, port=3
178+
TenGigEthernet0/1/2/3 -> rack=0, slot=1, subslot=2, port=3
179+
HundredGigE0/0/0/1 -> rack=0, slot=0, subslot=0, port=1
180+
Port-Channel1.2405 -> single logical interface
181+
Juniper:
182+
ge-0/0/1 -> rack=0, slot=0, subslot=0, port=1
183+
xe-1/2/0 -> rack=1, slot=2, subslot=0, port=0
184+
et-0/0/2 -> rack=0, slot=0, subslot=2, port=2
185+
ae1 -> prefix=ae, port=1
186+
"""
187+
# Normalize whitespace and strip subinterfaces
188+
name = name.strip()
189+
base_name = name.split(".")[0]
190+
191+
# Juniper-style (starts with ge-, xe-, et-, ae-, etc.)
192+
juniper_pattern = r"^([a-z]{2,3})-(\d+)(?:/(\d+))?(?:/(\d+))?(?:/(\d+))?$"
193+
junos = re.match(juniper_pattern, base_name, re.IGNORECASE)
194+
if junos:
195+
prefix = junos.group(1)
196+
nums = [int(n) for n in junos.groups()[1:] if n is not None]
197+
# Pad right side with None so we can unpack 4 positions
198+
while len(nums) < 4:
199+
nums.append(None)
200+
rack, slot, subslot, port = nums[:4]
201+
return prefix, rack, slot, subslot, port
202+
203+
# Cisco-style (letters followed by numeric segments)
204+
cisco_pattern = r"^([A-Za-z\-]+)([\d/\.]*)$"
205+
m = re.match(cisco_pattern, base_name)
206+
if not m:
207+
# Non-matching — treat entire thing as logical name
208+
return name, None, None, None, None
209+
210+
prefix, rest = m.groups()
211+
# Get all numeric parts (ignore trailing dot segments)
212+
nums = [int(n) for n in rest.split("/") if n.isdigit()]
213+
214+
rack = slot = subslot = port = None
215+
if len(nums) == 1:
216+
# e.g. Mgmt0, Vlan100
217+
port = nums[0]
218+
elif len(nums) == 2:
219+
# e.g. Ethernet2/43
220+
slot, port = nums
221+
elif len(nums) == 3:
222+
# e.g. GigabitEthernet1/2/3
223+
slot, subslot, port = nums
224+
elif len(nums) >= 4:
225+
# e.g. TenGigEthernet0/1/2/3, HundredGigE0/0/0/1
226+
rack, slot, subslot, port = nums[:4]
227+
228+
return prefix, rack, slot, subslot, port
229+
170230
def reverse_dns(ip):
171231
try:
172232
return socket.gethostbyaddr(ip)[0].split('.')[0]

src/network_ops_dashboard/inventory/discovery/views.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
from django.contrib.auth.decorators import login_required
22
from django.views.decorators.http import require_POST
33
from django.shortcuts import render, redirect, get_object_or_404
4+
from django.template.loader import render_to_string
45
from django.utils import timezone
6+
from django.http import HttpResponse
57
from network_ops_dashboard.inventory.discovery.scripts.services import run_discovery
68
from network_ops_dashboard.inventory.discovery.forms import DiscoveryForm
79
from network_ops_dashboard.inventory.models import Inventory, InventoryInterface, Platform, Site
810
from network_ops_dashboard.inventory.discovery.models import DiscoveryJob, DiscoveredDevice
911
from network_ops_dashboard.inventory.discovery.tasks import start_discovery_in_thread
12+
from network_ops_dashboard.inventory.discovery.scripts.services import parse_interface_name
1013

1114
@require_POST
1215
@login_required(login_url='/accounts/login/')
@@ -42,7 +45,6 @@ def inventory_discovery_results(request, job_id):
4245
def inventory_discovery_install(request, device_id):
4346
d = get_object_or_404(DiscoveredDevice, pk=device_id)
4447

45-
# Get overrides from form
4648
hostname = request.POST.get("hostname") or d.hostname or d.ip
4749
vendor = d.raw.get("vendor", "")
4850
model = request.POST.get("model") or d.raw.get("model", "")
@@ -51,15 +53,16 @@ def inventory_discovery_install(request, device_id):
5153
serial = d.raw.get("serial", "")
5254
site_id = request.POST.get("site")
5355
site_obj = get_object_or_404(Site, pk=site_id)
56+
if not site_id or not site_id.isdigit():
57+
return HttpResponse("Please select a site before installing.", status=400)
58+
site_obj = get_object_or_404(Site, pk=int(site_id))
5459

55-
# Find or create Platform entry
5660
platform_obj, _ = Platform.objects.get_or_create(
5761
manufacturer=vendor,
5862
PID=pid,
5963
name=model,
6064
)
6165

62-
# Create Inventory device
6366
new_dev = Inventory.objects.create(
6467
name=hostname,
6568
name_lookup=hostname,
@@ -73,11 +76,34 @@ def inventory_discovery_install(request, device_id):
7376
)
7477

7578
for iface in d.raw.get("interfaces", []):
76-
InventoryInterface.objects.create(device=new_dev, name=iface)
79+
prefix, rack, slot, subslot, port = parse_interface_name(iface)
80+
InventoryInterface.objects.create(
81+
device=new_dev,
82+
name=iface,
83+
prefix=prefix,
84+
rack=rack,
85+
slot=slot,
86+
subslot=subslot,
87+
port=port,
88+
)
7789

90+
# Mark this discovered device as installed
7891
d.added_to_inventory = True
7992
d.save()
8093

94+
# HTMX request > re-render the _status partial
95+
if request.headers.get("HX-Request"):
96+
# Grab the job again so we get updated devices
97+
job = d.job
98+
devices = job.devices.all().order_by("ip")
99+
sites = Site.objects.all()
100+
html = render_to_string(
101+
"network_ops_dashboard/inventory/discovery/_status.html",
102+
{"job": job, "devices": devices, "sites": sites},
103+
request=request,
104+
)
105+
return HttpResponse(html)
106+
81107
return redirect("inventory_home")
82108

83109
@login_required(login_url='/accounts/login/')

src/network_ops_dashboard/inventory/forms.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,41 @@
88
class InventoryForm(forms.ModelForm):
99
class Meta:
1010
model = Inventory
11-
fields = ('name', 'name_lookup', 'site', 'platform', 'serial_number', \
12-
'ipaddress_mgmt', 'ipaddress_rest', 'ipaddress_gmni', 'port_rest', 'port_netc', \
11+
fields = ('status', 'name', 'name_lookup', 'site', 'platform', 'serial_number', \
12+
'ipaddress_mgmt', 'ipaddress_rest', 'ipaddress_gnmi', 'port_rest', 'port_netc', \
1313
'port_gnmi', 'device_tag', 'creds_ssh', 'creds_rest')
14-
name = forms.CharField(label="Device Name:", help_text="<br>Enter name that is resolved from mgmt IP excluding<br>the domain/subdomain.", required=True)
15-
name_lookup = forms.CharField(label="Name Lookup:", help_text="<br>ie: devicename.companyname.com", required=False)
14+
DEVICE_STATUS_CHOICES = (
15+
("ACTIVE", "Active"),
16+
("STAGING", "Staging"),
17+
("MAINT", "Maintenance"),
18+
("RETIRED", "Retired"),
19+
)
20+
status = forms.ChoiceField(label="Status:", choices=DEVICE_STATUS_CHOICES, required=True)
21+
name = forms.CharField(label="Hostname:", help_text="<br>Hostname of device", required=True)
22+
name_lookup = forms.CharField(label="FQDN:", help_text="<br>ie: devicename.companyname.com", required=False)
1623
site = forms.ModelChoiceField(label="Site:", queryset=Site.objects.all(), required=False)
1724
platform = forms.ModelChoiceField(label="Platform:", queryset=Platform.objects.all(), required=False)
1825
serial_number = forms.CharField(label="Serial Number:", required=False)
1926
ipaddress_mgmt = forms.GenericIPAddressField(label="IP Address (MGMT):", initial='0.0.0.0')
2027
ipaddress_rest = forms.GenericIPAddressField(label="IP Address (REST):", initial='0.0.0.0')
21-
ipaddress_gmni = forms.GenericIPAddressField(label="IP Address (gNMI):", initial='0.0.0.0')
28+
ipaddress_gnmi = forms.GenericIPAddressField(label="IP Address (gNMI):", initial='0.0.0.0')
2229
port_rest = forms.CharField(label="Port (REST):", initial='443')
2330
port_netc = forms.CharField(label="Port (NETCONF):", initial='830')
2431
port_gnmi = forms.CharField(label="Port (gNMI):", initial='9339')
2532
device_tag = forms.ModelMultipleChoiceField(label="Device Tags:", queryset=DeviceTag.objects.all(), widget=forms.SelectMultiple(attrs={'class': 'form-select'}), required=False)
26-
# priority_interfaces = forms.CharField(label="Priority Interfaces:", help_text="<br>ie: GigabitEthernet2/1/1, TenGigabitEthernet1/1/1, etc", required=False)
2733
creds_ssh = forms.ModelChoiceField(label="SSH Credential:", queryset=NetworkCredential.objects.all(), required=False)
2834
creds_rest = forms.ModelChoiceField(label="REST Credential:", queryset=NetworkCredential.objects.all(), required=False)
2935

36+
def __init__(self, *args, **kwargs):
37+
super().__init__(*args, **kwargs)
38+
# Handle instances with missing or blank IP fields
39+
instance = kwargs.get("instance")
40+
if instance:
41+
for field_name in ("ipaddress_rest", "ipaddress_gnmi"):
42+
value = getattr(instance, field_name, None)
43+
if not value:
44+
self.initial[field_name] = "0.0.0.0"
45+
3046
class PlatformForm(forms.ModelForm):
3147
class Meta:
3248
model = Platform

src/network_ops_dashboard/inventory/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ class Status(models.TextChoices):
152152
serial_number = models.CharField(max_length=50, blank=True, default="")
153153
# Management
154154
ipaddress_mgmt = models.GenericIPAddressField(protocol='IPv4', null=True, blank=True)
155-
ipaddress_rest = models.GenericIPAddressField(protocol='IPv4', null=True, blank=True)
156-
ipaddress_gnmi = models.GenericIPAddressField(protocol='IPv4', null=True, blank=True)
155+
ipaddress_rest = models.GenericIPAddressField(protocol='IPv4', null=True, blank=True, default='0.0.0.0')
156+
ipaddress_gnmi = models.GenericIPAddressField(protocol='IPv4', null=True, blank=True, default='0.0.0.0')
157157
port_rest = models.IntegerField(default=443, validators=[MinValueValidator(1), MaxValueValidator(65535)])
158158
port_netc = models.IntegerField(default=830, validators=[MinValueValidator(1), MaxValueValidator(65535)])
159159
port_gnmi = models.IntegerField(default=9339, validators=[MinValueValidator(1), MaxValueValidator(65535)])

src/network_ops_dashboard/templates/network_ops_dashboard/inventory/add.html

Lines changed: 0 additions & 47 deletions
This file was deleted.

0 commit comments

Comments
 (0)