Skip to content

Commit d7310a9

Browse files
Device interface(s) tab for tracking (#30)
* (feature): First Phase plumbing for Inventory/Device Interface options and tracking/priorty interface selection. * (feature): Implement new tabular UX design for device CRUD in particular the Interfaces tab. Ability to add interfaces in order to "track" for Statseeker alarms and future feature implementations. * (feature): Updated dockerfile.
1 parent b74903f commit d7310a9

File tree

8 files changed

+262
-37
lines changed

8 files changed

+262
-37
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## v0.3.0 - 10-10-2025
4+
- 🖥️ Inventory: Re-design into single modal/page for CRUD process.
5+
- 🆕 Inventory Tracked Interface: Added ability to add interfaces in order to "track" for particular scripts and cards such as Statseeker
6+
7+
## v0.2.9 - 10-3-2025
8+
- 🆕 Inventory Discovery: Added SNMP/SSH device discovery functionality.
9+
- 🗃️ DiscoveryJob Tasks: Added capability for job queuing discovery tasks.
10+
11+
## v0.2.8 - 09-26-2025
12+
- 🆕 CiscoAdvisory: Added user assignment and status workflow.
13+
- ✅ On-call: Fix parsehandler bugs for Cogent email processing.
14+
- ✅ ASAVPN: Fixed unreachable stats.
15+
316
## v0.2.7 - 09-03-2025
417
- 🆕 CiscoAdvisory: Added functionality to interact with field notices to maintain impact and remediation.
518
- ⚙️ CiscoAdvisory: Field Notices set as "No Impact" will auto-archive at specified On-Call auto-archive timeframe.

Dockerfile.ci

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ ENV PYTHONDONTWRITEBYTECODE=1
2020

2121
# Install Python dependencies
2222
COPY requirements.txt .
23-
RUN pip install --no-cache-dir -r requirements.txt
23+
RUN pip install --upgrade pip
24+
RUN pip install --no-cache-dir --timeout=60 -r requirements.txt
2425

2526
# Copy App
2627
COPY . .

src/network_ops_dashboard/inventory/forms.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ class Meta:
1010
model = Inventory
1111
fields = ('name', 'name_lookup', 'site', 'platform', 'serial_number', \
1212
'ipaddress_mgmt', 'ipaddress_rest', 'ipaddress_gmni', 'port_rest', 'port_netc', \
13-
'port_gnmi', 'device_tag', 'priority_interfaces', 'creds_ssh', 'creds_rest')
13+
'port_gnmi', 'device_tag', 'creds_ssh', 'creds_rest')
1414
name = forms.CharField(label="Device Name:", help_text="<br>Enter name that is resolved from mgmt IP excluding<br>the domain/subdomain.", required=True)
1515
name_lookup = forms.CharField(label="Name Lookup:", help_text="<br>ie: devicename.companyname.com", required=False)
1616
site = forms.ModelChoiceField(label="Site:", queryset=Site.objects.all(), required=False)
@@ -23,7 +23,7 @@ class Meta:
2323
port_netc = forms.CharField(label="Port (NETCONF):", initial='830')
2424
port_gnmi = forms.CharField(label="Port (gNMI):", initial='9339')
2525
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)
26+
# priority_interfaces = forms.CharField(label="Priority Interfaces:", help_text="<br>ie: GigabitEthernet2/1/1, TenGigabitEthernet1/1/1, etc", required=False)
2727
creds_ssh = forms.ModelChoiceField(label="SSH Credential:", queryset=NetworkCredential.objects.all(), required=False)
2828
creds_rest = forms.ModelChoiceField(label="REST Credential:", queryset=NetworkCredential.objects.all(), required=False)
2929

src/network_ops_dashboard/inventory/models.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ class Status(models.TextChoices):
171171
discovery_source = models.CharField(max_length=64, blank=True, default="")
172172
sw_version = models.CharField(max_length=128, blank=True, default="")
173173
# Priority/Tracked Interfaces
174-
priority_interfaces = models.CharField(max_length=750, blank=True)
174+
priority_interfaces = models.JSONField(default=list, blank=True, null=True)
175+
# stores ["Ethernet1/1", "Ethernet2/43", "mgmt0"]
175176
def get_priority_interfaces(self):
176177
return [k.strip() for k in (self.priority_interfaces or "").split(",") if k.strip()]
177178
def set_priority_interfaces(self, items):
@@ -194,6 +195,31 @@ def __str__(self):
194195
return str(self.name)
195196

196197
class InventoryInterface(models.Model):
197-
device = models.ForeignKey(Inventory, related_name="interfaces", on_delete=models.CASCADE)
198-
name = models.CharField(max_length=128)
199-
is_priority = models.BooleanField(default=False)
198+
device = models.ForeignKey(
199+
Inventory,
200+
related_name="interfaces",
201+
on_delete=models.CASCADE
202+
)
203+
name = models.CharField(max_length=64)
204+
prefix = models.CharField(max_length=32, null=True, blank=True)
205+
rack = models.PositiveSmallIntegerField(null=True, blank=True)
206+
slot = models.PositiveSmallIntegerField(null=True, blank=True)
207+
subslot = models.PositiveSmallIntegerField(null=True, blank=True)
208+
port = models.PositiveSmallIntegerField(null=True, blank=True)
209+
is_priority = models.BooleanField(default=False)
210+
211+
def display_name(self):
212+
if self.prefix:
213+
nums = [n for n in [self.rack, self.slot, self.subslot, self.port] if n is not None]
214+
suffix = "/".join(str(n) for n in nums)
215+
return f"{self.prefix}{suffix}" if suffix else self.prefix
216+
return self.name
217+
218+
class Meta:
219+
ordering = ["rack", "slot", "subslot", "port", "name"]
220+
constraints = [
221+
models.UniqueConstraint(fields=["device", "name"], name="uniq_device_interface")
222+
]
223+
224+
def __str__(self):
225+
return f"{self.device.name} {self.name}"

src/network_ops_dashboard/inventory/views.py

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
from django.views.decorators.cache import never_cache
44
from django.http import JsonResponse, HttpResponse
55
from django.db.models import Q
6+
from django.urls import reverse
7+
from django.contrib import messages
68
import logging
79
from network_ops_dashboard.decorators import *
810
from network_ops_dashboard.models import *
9-
from network_ops_dashboard.inventory.models import Inventory, Site, Platform, DeviceTag
11+
from network_ops_dashboard.inventory.models import Inventory, InventoryInterface, Site, Platform, DeviceTag
1012
from network_ops_dashboard.inventory.discovery.forms import DiscoveryForm
1113
from network_ops_dashboard.inventory.forms import *
1214

@@ -29,17 +31,96 @@ def inventory_home(request):
2931
"discovery_form": discovery_form,
3032
})
3133

32-
@login_required(login_url='/accounts/login/')
34+
@login_required
3335
def inventory_edit_modal(request, pk):
3436
device = get_object_or_404(Inventory, pk=pk)
37+
3538
if request.method == "POST":
3639
form = InventoryForm(request.POST, instance=device)
3740
if form.is_valid():
3841
form.save()
39-
return HttpResponse('<script>window.location.reload()</script>')
42+
43+
# --- Update Priority Interfaces ---
44+
selected_ids = request.POST.getlist("priority_interfaces")
45+
device.interfaces.update(is_priority=False)
46+
if selected_ids:
47+
device.interfaces.filter(id__in=selected_ids).update(is_priority=True)
48+
49+
# --- Add New Interfaces ---
50+
prefix = request.POST.get("prefix")
51+
rack = request.POST.get("rack")
52+
slot = request.POST.get("slot")
53+
port_start = request.POST.get("port_start")
54+
port_end = request.POST.get("port_end") or port_start
55+
56+
if prefix:
57+
if port_start: # Range / numbered interfaces
58+
try:
59+
start = int(port_start)
60+
end = int(port_end)
61+
except ValueError:
62+
start, end = None, None
63+
64+
if start is not None and end is not None:
65+
for port in range(start, end + 1):
66+
slot_bits = []
67+
if rack:
68+
slot_bits.append(str(rack))
69+
if slot:
70+
slot_bits.append(str(slot))
71+
slot_bits.append(str(port))
72+
slot_str = "/".join(slot_bits)
73+
74+
iface_name = f"{prefix}{slot_str}"
75+
76+
InventoryInterface.objects.get_or_create(
77+
device=device,
78+
name=iface_name,
79+
defaults={
80+
"rack": int(rack) if rack else None,
81+
"slot": int(slot) if slot else None,
82+
"port": port,
83+
},
84+
)
85+
else:
86+
# Singleton free-text interface (Mgmt0, Port-Channel1.2405, Vlan10…)
87+
InventoryInterface.objects.get_or_create(
88+
device=device,
89+
name=prefix.strip(),
90+
defaults={
91+
"rack": int(rack) if rack else None,
92+
"slot": int(slot) if slot else None,
93+
},
94+
)
95+
96+
if request.POST.get("action") == "add_interface":
97+
# Stay in modal > re-render with updated interface list
98+
interfaces = device.interfaces.all().order_by("rack", "slot", "port", "name")
99+
return render(
100+
request,
101+
"network_ops_dashboard/inventory/_interfaces_list.html",
102+
{"device": device, "interfaces": interfaces, "form": form},
103+
)
104+
else:
105+
# Save clicked > close modal + refresh page
106+
resp = HttpResponse()
107+
resp["HX-Redirect"] = reverse("inventory_home")
108+
return resp
109+
40110
else:
41111
form = InventoryForm(instance=device)
42-
return render(request, "network_ops_dashboard/inventory/_inventory_form.html", {"form": form, "device": device})
112+
113+
interfaces = device.interfaces.all().order_by("rack", "slot", "port", "name")
114+
return render(
115+
request,
116+
"network_ops_dashboard/inventory/_inventory_form.html",
117+
{
118+
"device": device,
119+
"form": form,
120+
"interfaces": interfaces,
121+
},
122+
)
123+
43124

44125
@login_required(login_url='/accounts/login/')
45126
def inventory_add_modal(request):
@@ -104,6 +185,29 @@ def inventory_data(request):
104185
response["Pragma"] = "no-cache"
105186
return response
106187

188+
@login_required
189+
def inventory_add_interface(request, pk):
190+
device = get_object_or_404(Inventory, pk=pk)
191+
192+
if request.method == "POST":
193+
prefix = request.POST.get("prefix")
194+
slot = request.POST.get("slot") or None
195+
port_start = int(request.POST.get("port_start"))
196+
port_end = request.POST.get("port_end")
197+
port_end = int(port_end) if port_end else port_start
198+
199+
for port in range(port_start, port_end + 1):
200+
InventoryInterface.objects.get_or_create(
201+
device=device,
202+
prefix=prefix,
203+
slot=slot,
204+
port=port,
205+
defaults={"name": f"{prefix}{slot}/{port}" if slot else f"{prefix}{port}"}
206+
)
207+
208+
messages.success(request, f"Added interfaces {prefix}{slot}/{port_start}-{port_end}")
209+
return redirect("inventory_edit_modal", pk=device.id)
210+
107211
@login_required(login_url='/accounts/login/')
108212
def platform_home(request):
109213
platform_all = Platform.objects.all().order_by('name')
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<div id="interfaces-list">
2+
<div class="accordion" id="interfacesAccordion">
3+
{% regroup interfaces by slot as slot_list %}
4+
{% for slot in slot_list %}
5+
<div class="accordion-item">
6+
<h2 class="accordion-header" id="heading{{ forloop.counter }}">
7+
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}">
8+
Slot {{ slot.grouper|default:"N/A" }}
9+
</button>
10+
</h2>
11+
<div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse">
12+
<div class="accordion-body">
13+
{% for iface in slot.list %}
14+
<div class="form-check">
15+
<input class="form-check-input" type="checkbox" name="priority_interfaces" value="{{ iface.id }}" id="iface{{ iface.id }}" {% if iface.is_priority %}checked{% endif %}>
16+
<label class="form-check-label" for="iface{{ iface.id }}">{{ iface.display_name }}</label>
17+
</div>
18+
{% endfor %}
19+
</div>
20+
</div>
21+
</div>
22+
{% endfor %}
23+
</div>
24+
25+
<!-- Inline Add Form -->
26+
<div class="mt-3">
27+
<div class="row g-2">
28+
<div class="col"><input type="text" name="prefix" class="form-control" placeholder="Prefix/Name"></div>
29+
<div class="col"><input type="text" name="rack" class="form-control" placeholder="Rack#"></div>
30+
<div class="col"><input type="text" name="slot" class="form-control" placeholder="Slot#"></div>
31+
<div class="col"><input type="text" name="port_start" class="form-control" placeholder="Port Start"></div>
32+
<div class="col"><input type="text" name="port_end" class="form-control" placeholder="Port End"></div>
33+
<div class="col-auto">
34+
<button type="submit"
35+
name="action"
36+
value="add_interface"
37+
class="btn btn-sm btn-primary"
38+
hx-post="{% url 'inventory_edit_modal' device.pk %}"
39+
hx-target="#interfaces-list"
40+
hx-swap="outerHTML">
41+
Add
42+
</button>
43+
</div>
44+
<div class="form-text mt-1">
45+
<p class="mb-0 text-muted small">* To configure a range enter Prefix name ("Ethernet", "GigabitEthernet", etc) plus rack/slot and start/end ports.</p>
46+
<p class="mb-0 text-muted small">* For singletons just enter full name into Interface Name (eg "Port-Channel220", "Vlan220", "Mgmt0").</p>
47+
<p class="mb-0 text-muted small">* Rack# is mainly for IOS-XR chassis like CRS/ASR9K.</p>
48+
</div>
49+
</div>
50+
</div>
51+
</div>
Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,59 @@
1-
{% block content %}
21
<div class="modal-header">
3-
<h5 class="modal-title">
4-
{% if device %}Edit {{ device.name }}{% else %}Add Network Device{% endif %}
5-
</h5>
6-
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
7-
</div>
8-
<form method="post"
9-
hx-post="{% if device %}{% url 'inventory_edit_modal' device.pk %}{% else %}{% url 'inventory_add_modal' %}{% endif %}"
10-
hx-target="#inventoryModal .modal-content"
11-
hx-swap="outerHTML">
12-
{% csrf_token %}
13-
<div class="modal-body">
2+
<h5 class="modal-title">
3+
{% if device %}Edit {{ device.name }}{% else %}Add Network Device{% endif %}
4+
</h5>
5+
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
6+
</div>
7+
8+
<form method="post"
9+
hx-post="{% if device %}{% url 'inventory_edit_modal' device.pk %}{% else %}{% url 'inventory_add_modal' %}{% endif %}"
10+
hx-target="#inventoryModal .modal-content"
11+
hx-swap="outerHTML">
12+
{% csrf_token %}
13+
14+
<ul class="nav nav-tabs" id="invTab" role="tablist">
15+
<li class="nav-item">
16+
<a class="nav-link active" data-bs-toggle="tab" href="#deviceTab">Device</a>
17+
</li>
18+
<li class="nav-item">
19+
<a class="nav-link" data-bs-toggle="tab" href="#interfacesTab">Interfaces</a>
20+
</li>
21+
<li class="nav-item">
22+
<a class="nav-link" data-bs-toggle="tab" href="#configTab">Config</a>
23+
</li>
24+
</ul>
25+
26+
<div class="tab-content">
27+
<!-- Device tab -->
28+
<div class="tab-pane fade show active p-3" id="deviceTab">
1429
{{ form.as_p }}
1530
</div>
16-
<div class="modal-footer">
17-
<button type="submit" class="btn btn-primary">Save</button>
18-
{% if device %}
19-
<button type="button"
20-
class="btn btn-danger"
21-
hx-post="{% url 'inventory_delete_modal' device.pk %}"
22-
hx-vals='{"_method":"DELETE"}'
23-
hx-confirm="Really delete {{ device.name }}?">
24-
Delete
25-
</button>
26-
{% endif %}
27-
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
31+
32+
<!-- Interfaces tab -->
33+
<div class="tab-pane fade p-3" id="interfacesTab" role="tabpanel">
34+
<div class="d-flex justify-content-between align-items-center mb-2">
35+
<p class="mb-0 text-muted small">✅ Check an interface to track</p>
36+
</div>
37+
{% include "network_ops_dashboard/inventory/_interfaces_list.html" %}
38+
</div>
39+
40+
<!-- Config tab -->
41+
<div class="tab-pane fade p-3" id="configTab">
42+
<em>Coming soon…</em>
2843
</div>
29-
</form>
30-
{% endblock %}
44+
</div>
45+
46+
<div class="modal-footer">
47+
<button type="submit" class="btn btn-primary">Save</button>
48+
{% if device %}
49+
<button type="button"
50+
class="btn btn-danger"
51+
hx-post="{% url 'inventory_delete_modal' device.pk %}"
52+
hx-vals='{"_method":"DELETE"}'
53+
hx-confirm="Really delete {{ device.name }}?">
54+
Delete
55+
</button>
56+
{% endif %}
57+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
58+
</div>
59+
</form>

src/network_ops_dashboard/templates/network_ops_dashboard/inventory/home.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{% extends 'network_ops_dashboard/base.html' %}
22
{% block content %}
3+
34
<h3>Network Inventory</h3>
45

56
<form id="filters" class="row g-2 align-items-end mb-3">

0 commit comments

Comments
 (0)