Feature: Add customisable icons in topology (#193)

* [up] add config for custom images, better js

* [up] use bundler to bundle js

* [fix] remove domcontentloaded since defer is used

* [fix] formatting with black

* [fix] images drf api

* [up] add support for custom icons on additional roles: power panel, power feed, circuit

* [up] optimize topology generation by using `_id` when trying to access pk of related object

* [fix] kebab case instead of slugification

* [fix] use empty queryset in api views & change coord save logic
This commit is contained in:
Victor Gorchilov
2022-12-09 13:40:39 +01:00
committed by GitHub
parent 5c87694b87
commit 52c2611e9d
30 changed files with 1395 additions and 628 deletions
+23 -22
View File
@@ -36,19 +36,20 @@ Then run
```bash ```bash
cd /opt/netbox/netbox cd /opt/netbox/netbox
pip3 install netbox-topology-views pip3 install netbox-topology-views
python3 manage.py migrate netbox_topology_views
python3 manage.py collectstatic --no-input python3 manage.py collectstatic --no-input
``` ```
### Versions ### Versions
| netbox version | netbox-topology-views version | | netbox version | netbox-topology-views version |
| ------------- |-------------| | -------------- | ----------------------------- |
| >= 3.3.0 | >= v3.0.0 | | >= 3.3.0 | >= v3.0.0 |
| >= 3.2.0 | >= v1.1.0 | | >= 3.2.0 | >= v1.1.0 |
| >= 3.1.8 | >= v1.0.0 | | >= 3.1.8 | >= v1.0.0 |
| >= 2.11.1 | >= v0.5.3 | | >= 2.11.1 | >= v0.5.3 |
| >= 2.10.0 | >= v0.5.0 | | >= 2.10.0 | >= v0.5.0 |
| < 2.10.0 | =< v0.4.10 | | < 2.10.0 | =< v0.4.10 |
### Custom field: coordinates ### Custom field: coordinates
@@ -69,27 +70,25 @@ Example:
``` ```
PLUGINS_CONFIG = { PLUGINS_CONFIG = {
'netbox_topology_views': { 'netbox_topology_views': {
'device_img': ['router','switch', 'firewall'], 'preselected_device_roles': ['Router', 'Firewall'],
'preselected_device_roles': ['Router', 'Firewall'] 'enable_circuit_terminations': True
} }
} }
``` ```
| Setting | Default value | Description | | Setting | Default value | Description |
| ------------- |-------------| -----| | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| device_img |['access-switch', 'core-switch', 'firewall', 'router', 'distribution-switch', 'backup', 'storage,wan-network', 'wireless-ap', 'server', 'internal-switch', 'isp-cpe-material', 'non-racked-devices', 'power-units'] | The slug of the device roles that you have a image for. | | static_image_directory | netbox_topology_views/img | (str or pathlib.Path) Specifies the location that images will be loaded from by default. Must be within `STATIC_ROOT` |
| preselected_device_roles | ['Firewall', 'Router', 'Distribution Switch', 'Core Switch', 'Internal Switch', 'Access Switch', 'Server', 'Storage', 'Backup', 'Wireless AP'] | The full name of the device roles you want to pre select in the global view. Note that this is case sensitive| | preselected_device_roles | ['Firewall', 'Router', 'Distribution Switch', 'Core Switch', 'Internal Switch', 'Access Switch', 'Server', 'Storage', 'Backup', 'Wireless AP'] | The full name of the device roles you want to pre select in the global view. Note that this is case sensitive |
| allow_coordinates_saving | False | (bool) Set to true if you use the custom coordinates fields and want to save the coordinates | | allow_coordinates_saving | False | (bool) Set to true if you use the custom coordinates fields and want to save the coordinates |
| always_save_coordinates | False | (bool) Set if you want to enable the option to save coordinates by default | | always_save_coordinates | False | (bool) Set if you want to enable the option to save coordinates by default |
| ignore_cable_type | [] | The cable types that you want to ignore in the views | | ignore_cable_type | [] | The cable types that you want to ignore in the views |
| preselected_tags | '[]' | The name of tags you want to preload | | preselected_tags | [] | The name of tags you want to preload |
| draw_default_layout | False | (bool) Set to True if you want to load draw the topology on the initial load (when you go to the topology plugin page) | | draw_default_layout | False | (bool) Set to True if you want to load draw the topology on the initial load (when you go to the topology plugin page) |
### Custom Images ### Custom Images
You upload you own custom images to the netbox static dir (`static/netbox_topology_views/img/`). To change image with associated device use the `Images` page - it allows to map a device role with an image found in the netbox static directory (defined by the plugin config `static_image_directory` which defaults to `netbox_topology_views/img`). You can also upload you own custom images to there - these images will automatically be used for a device (if it does not already have a specified image in the settings) if their name is the device role slug.
These images need to be named after de device role slug and have the .png format/extension.
If you add your own image you also need to add the slug to the `device_img` setting.
## Use ## Use
@@ -99,6 +98,8 @@ Go to the plugins tab in the navbar and click topology or go to `$NETBOX_URL/plu
Run `pip install netbox-topology-views --upgrade` in your venv. Run `pip install netbox-topology-views --upgrade` in your venv.
Run `python3 manage.py migrate netbox_topology_views`
Run `python3 manage.py collectstatic --no-input` Run `python3 manage.py collectstatic --no-input`
+27 -14
View File
@@ -1,22 +1,35 @@
from extras.plugins import PluginConfig from extras.plugins import PluginConfig
class TopologyViewsConfig(PluginConfig): class TopologyViewsConfig(PluginConfig):
name = 'netbox_topology_views' name = "netbox_topology_views"
verbose_name = 'Topology views' verbose_name = "Topology views"
description = 'An plugin to render topology maps' description = "An plugin to render topology maps"
version = '3.0.1' version = "3.0.1"
author = 'Mattijs Vanhaverbeke' author = "Mattijs Vanhaverbeke"
author_email = 'author@example.com' author_email = "author@example.com"
base_url = 'netbox_topology_views' base_url = "netbox_topology_views"
required_settings = [] required_settings = []
default_settings = { default_settings = {
'preselected_device_roles': ['Firewall', 'Router', 'Distribution Switch', 'Core Switch', 'Internal Switch', 'Access Switch', 'Server', 'Storage', 'Backup', 'Wireless AP'], "preselected_device_roles": [
'ignore_cable_type': [], "Firewall",
'device_img': ['access-switch', 'core-switch', 'firewall', 'router', 'distribution-switch', 'backup', 'storage', 'wan-network', 'wireless-ap', 'server', 'internal-switch', 'isp-cpe-material', 'non-racked-devices', 'power-units'], "Router",
'allow_coordinates_saving': False, "Distribution Switch",
'always_save_coordinates': False, "Core Switch",
'preselected_tags' : [], "Internal Switch",
'draw_default_layout': False "Access Switch",
"Server",
"Storage",
"Backup",
"Wireless AP",
],
"ignore_cable_type": [],
"static_image_directory": "netbox_topology_views/img",
"allow_coordinates_saving": False,
"always_save_coordinates": False,
"preselected_tags": [],
"draw_default_layout": False,
} }
config = TopologyViewsConfig config = TopologyViewsConfig
+1 -1
View File
@@ -1 +1 @@
"""REST API""" """REST API"""
+15 -5
View File
@@ -1,12 +1,22 @@
from dcim.models import Device, DeviceRole
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from dcim.models import DeviceRole, Device from netbox_topology_views.models import RoleImage
from extras.models import Tag
class TopologyDummySerializer(ModelSerializer): class TopologyDummySerializer(ModelSerializer):
class Meta: class Meta:
model = Device model = Device
fields = ('id', 'name') fields = ("id", "name")
class RoleImageSerializer(ModelSerializer):
class Meta:
model = RoleImage
fields = ("role", "image")
class DeviceRoleSerializer(ModelSerializer):
class Meta:
model = DeviceRole
fields = ("name", "slug", "color", "vm_role", "description")
+7 -5
View File
@@ -1,8 +1,10 @@
from rest_framework import routers from netbox.api.routers import NetBoxRouter
from . import views
router = routers.DefaultRouter() from netbox_topology_views.api import views
router.register('save-coords', views.SaveCoordsViewSet, basename='save_coords') router = NetBoxRouter()
urlpatterns = router.urls router.register("save-coords", views.SaveCoordsViewSet)
router.register("images", views.SaveRoleImageViewSet)
urlpatterns = router.urls
+116 -58
View File
@@ -1,70 +1,128 @@
from rest_framework.viewsets import ModelViewSet, ViewSet, ReadOnlyModelViewSet, GenericViewSet from typing import Dict
from circuits.models import Circuit
from dcim.models import Device, DeviceRole, PowerFeed, PowerPanel
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.http import JsonResponse
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from django.contrib.contenttypes.models import ContentType from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
from rest_framework.routers import APIRootView
from .serializers import TopologyDummySerializer from netbox_topology_views.api.serializers import (
from django.conf import settings RoleImageSerializer,
TopologyDummySerializer,
from dcim.models import DeviceRole, Device, Cable , PowerPanel, PowerFeed )
from circuits.models import Circuit from netbox_topology_views.models import RoleImage
from extras.models import Tag from netbox_topology_views.utils import get_image_from_url
class TopologyViewsRootView(APIRootView):
def get_view_name(self):
return 'TopologyViews'
class SaveCoordsViewSet(ReadOnlyModelViewSet): class SaveCoordsViewSet(ReadOnlyModelViewSet):
queryset = Device.objects.all() queryset = Device.objects.none()
serializer_class = TopologyDummySerializer serializer_class = TopologyDummySerializer
@action(detail=False, methods=['patch']) @action(detail=False, methods=["patch"])
def save_coords(self, request): def save_coords(self, request):
results = {} if not settings.PLUGINS_CONFIG["netbox_topology_views"][
if settings.PLUGINS_CONFIG["netbox_topology_views"]["allow_coordinates_saving"]: "allow_coordinates_saving"
device_id = None ]:
x_coord = None return Response({"status": "not allowed to save coords"}, status=500)
y_coord = None
if "node_id" in request.data:
if request.data["node_id"]:
device_id = request.data["node_id"]
if "x" in request.data:
if request.data["x"]:
x_coord = request.data["x"]
if "y" in request.data:
if request.data["y"]:
y_coord = request.data["y"]
if device_id.startswith("c"):
device_id = device_id.lstrip('c')
actual_device= Circuit.objects.get(id=device_id)
elif device_id.startswith("p"):
device_id = device_id.lstrip('p')
actual_device= PowerPanel.objects.get(id=device_id)
elif device_id.startswith("f"):
device_id = device_id.lstrip('f')
actual_device= PowerFeed.objects.get(id=device_id)
else:
actual_device= Device.objects.get(id=device_id)
if "coordinates" in actual_device.custom_field_data: device_id: str = request.data.get("node_id", None)
actual_device.custom_field_data["coordinates"] = "%s;%s" % (x_coord,y_coord) x_coord = request.data.get("x", None)
actual_device.save() y_coord = request.data.get("y", None)
results["status"] = "saved coords"
else:
try:
actual_device.custom_field_data["coordinates"] = "%s;%s" % (x_coord,y_coord)
actual_device.save()
results["status"] = "saved coords"
except :
results["status"] = "coords custom field not created"
return Response(status=500)
return Response(results) actual_device = None
else: if device_id.startswith("c"):
results["status"] = "not allowed to save coords" device_id = device_id.lstrip("c")
return Response(results, status=500) actual_device = Circuit.objects.get(id=device_id)
elif device_id.startswith("p"):
device_id = device_id.lstrip("p")
actual_device = PowerPanel.objects.get(id=device_id)
elif device_id.startswith("f"):
device_id = device_id.lstrip("f")
actual_device = PowerFeed.objects.get(id=device_id)
elif device_id.isnumeric():
actual_device = Device.objects.get(id=device_id)
if not actual_device:
return Response({"status": "invalid node_id in body"}, status=400)
try:
actual_device.custom_field_data["coordinates"] = "%s;%s" % (
x_coord,
y_coord,
)
actual_device.save()
except:
return Response(
{"status": "coords custom field could not be saved"}, status=500
)
return Response({"status": "saved coords"})
class SaveRoleImageViewSet(PermissionRequiredMixin, ViewSet):
queryset = DeviceRole.objects.none()
serializer_class = RoleImageSerializer
permission_required = (
"dcim.add_device_role",
"dcim.change_device_role",
)
def create(self, request):
if not isinstance(request.data, dict):
return JsonResponse(
{"status": "Missing or malformed request body"}, status=400
)
device_roles = {k: v for k, v in request.data.items() if k.isnumeric()}
content_type_ids = {
k[2:]: v
for k, v in request.data.items()
if k.startswith("ct") and k[2:].isnumeric()
}
roles: Dict[int, DeviceRole] = DeviceRole.objects.in_bulk(device_roles.keys())
content_types: Dict[int, ContentType] = ContentType.objects.in_bulk(
content_type_ids.keys()
)
if len(roles) != len(device_roles):
difference = set(device_roles) - set(roles.keys())
return JsonResponse(
{"status": f"Got unknown device role ids: {difference}"},
status=400,
)
if len(content_types) != len(content_type_ids):
difference = set(content_type_ids) - set(content_types.keys())
return JsonResponse(
{"status": f"Got unknown content type ids: {difference}"},
status=400,
)
if device_roles:
device_role_ct = ContentType.objects.get_for_model(DeviceRole)
for id, url in device_roles.items():
RoleImage.objects.update_or_create(
{
"content_type_id": device_role_ct.pk,
"object_id": id,
"image": str(get_image_from_url(url)),
},
object_id=id,
)
for content_type_id, url in content_type_ids.items():
RoleImage.objects.update_or_create(
{
"content_type_id": content_type_id,
"image": str(get_image_from_url(url)),
},
content_type_id=content_type_id,
)
return JsonResponse({"status": "Ok"})
+18 -26
View File
@@ -1,63 +1,55 @@
from cProfile import label
import django_filters import django_filters
from django.db.models import Q
from dcim.models import Device, DeviceRole, Region, Site, Location, Rack
from dcim.choices import DeviceStatusChoices from dcim.choices import DeviceStatusChoices
from dcim.models import Device, DeviceRole, Location, Rack, Region, Site
from django.db.models import Q
from netbox.filtersets import NetBoxModelFilterSet from netbox.filtersets import NetBoxModelFilterSet
from tenancy.models import TenantGroup, Tenant
from tenancy.filtersets import TenancyFilterSet from tenancy.filtersets import TenancyFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter from utilities.filters import TreeNodeMultipleChoiceFilter
class DeviceFilterSet(TenancyFilterSet, NetBoxModelFilterSet): class DeviceFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method="search",
label='Search', label="Search",
) )
device_role_id = django_filters.ModelMultipleChoiceFilter( device_role_id = django_filters.ModelMultipleChoiceFilter(
field_name='device_role_id', field_name="device_role_id",
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
label='Role (ID)', label="Role (ID)",
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.all(),
field_name='site__region', field_name="site__region",
lookup_expr='in', lookup_expr="in",
label='Region (ID)', label="Region (ID)",
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.all(),
label='Site (ID)', label="Site (ID)",
) )
location_id = TreeNodeMultipleChoiceFilter( location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(), queryset=Location.objects.all(),
field_name='location', field_name="location",
lookup_expr='in', lookup_expr="in",
label='Location (ID)', label="Location (ID)",
) )
rack_id = django_filters.ModelMultipleChoiceFilter( rack_id = django_filters.ModelMultipleChoiceFilter(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
field_name='rack_id', field_name="rack_id",
label='Rack (ID)', label="Rack (ID)",
) )
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=DeviceStatusChoices, choices=DeviceStatusChoices,
null_value=None, null_value=None,
) )
class Meta: class Meta:
model = Device model = Device
fields = ['id', 'name'] fields = ["id", "name"]
def search(self, queryset, name, value): def search(self, queryset, name, value):
"""Perform the filtered search.""" """Perform the filtered search."""
if not value.strip(): if not value.strip():
return queryset return queryset
qs_filter = ( qs_filter = Q(name__icontains=value)
Q(name__icontains=value)
)
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
+56 -40
View File
@@ -13,92 +13,108 @@ from tenancy.models import TenantGroup, Tenant
from tenancy.forms import TenancyFilterForm from tenancy.forms import TenancyFilterForm
from django.conf import settings from django.conf import settings
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from utilities.forms import (TagFilterField, DynamicModelMultipleChoiceField, MultipleChoiceField) from utilities.forms import (
TagFilterField,
DynamicModelMultipleChoiceField,
MultipleChoiceField,
)
allow_coordinates_saving = bool(settings.PLUGINS_CONFIG["netbox_topology_views"]["allow_coordinates_saving"]) allow_coordinates_saving = bool(
settings.PLUGINS_CONFIG["netbox_topology_views"]["allow_coordinates_saving"]
)
class DeviceFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class DeviceFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = Device model = Device
fieldsets = ( fieldsets = (
(None, ('q', 'hide_unconnected', 'save_coords', 'show_circuit', 'show_power' ,)), (
(None, ('tenant_group_id', 'tenant_id',)), None,
(None, ('region_id', 'site_id', 'location_id', 'rack_id')), (
(None, ('device_role_id','id','status', )), "q",
(None, ('tag', )), "hide_unconnected",
"save_coords",
"show_circuit",
"show_power",
),
),
(
None,
(
"tenant_group_id",
"tenant_id",
),
),
(None, ("region_id", "site_id", "location_id", "rack_id")),
(
None,
(
"device_role_id",
"id",
"status",
),
),
(None, ("tag",)),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(), required=False, label=_("Region")
required=False,
label=_('Region')
) )
device_role_id = DynamicModelMultipleChoiceField( device_role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(), required=False, label=_("Device Role")
required=False,
label=_('Device Role')
) )
id = DynamicModelMultipleChoiceField( id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,
label=_('Device'), label=_("Device"),
query_params={ query_params={
'location_id' : '$location_id', "location_id": "$location_id",
'region_id': '$region_id', "region_id": "$region_id",
'site_id': '$site_id', "site_id": "$site_id",
'role_id': '$device_role_id', "role_id": "$device_role_id",
}, },
) )
site_id = DynamicModelMultipleChoiceField( site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False, required=False,
query_params={ query_params={
'region_id': '$region_id', "region_id": "$region_id",
}, },
label=_('Site') label=_("Site"),
) )
location_id = DynamicModelMultipleChoiceField( location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(), queryset=Location.objects.all(),
required=False, required=False,
query_params={ query_params={
'region_id': '$region_id', "region_id": "$region_id",
'site_id': '$site_id', "site_id": "$site_id",
}, },
label=_('Location') label=_("Location"),
) )
rack_id = DynamicModelMultipleChoiceField( rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
required=False, required=False,
query_params={ query_params={
'region_id': '$region_id', "region_id": "$region_id",
'site_id': '$site_id', "site_id": "$site_id",
'location_id': '$location_id', "location_id": "$location_id",
}, },
label=_('Rack') label=_("Rack"),
) )
hide_unconnected = forms.BooleanField( hide_unconnected = forms.BooleanField(
label=_("Hide Unconnected"), label=_("Hide Unconnected"), required=False, initial=False
required=False,
initial=False
) )
show_circuit = forms.BooleanField( show_circuit = forms.BooleanField(
label=_("Show Circuit Terminations"), label=_("Show Circuit Terminations"), required=False, initial=False
required=False,
initial=False
) )
show_power = forms.BooleanField( show_power = forms.BooleanField(
label=_("Show Power Feeds"), label=_("Show Power Feeds"), required=False, initial=False
required=False,
initial=False
) )
save_coords = forms.BooleanField( save_coords = forms.BooleanField(
label=_("Save Coordinates"), label=_("Save Coordinates"),
required=False, required=False,
disabled=(not allow_coordinates_saving) disabled=(not allow_coordinates_saving),
) )
status = MultipleChoiceField( status = MultipleChoiceField(
choices=DeviceStatusChoices, choices=DeviceStatusChoices, required=False, label=_("Device Status")
required=False,
label=_("Device Status")
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -0,0 +1,31 @@
# Generated by Django 4.0.8 on 2022-12-06 13:07
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='RoleImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('image', models.CharField(max_length=255)),
('object_id', models.PositiveIntegerField(blank=True, null=True)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
],
),
migrations.AddIndex(
model_name='roleimage',
index=models.Index(fields=['content_type', 'object_id'], name='netbox_topo_content_9d87d4_idx'),
),
]
+93
View File
@@ -0,0 +1,93 @@
from pathlib import Path
from typing import Optional
from dcim.models import DeviceRole
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.templatetags.static import static
from netbox.models.features import (
ChangeLoggingMixin,
ExportTemplatesMixin,
WebhooksMixin,
)
from netbox_topology_views.utils import (
CONF_IMAGE_DIR,
IMAGE_DIR,
Role,
find_image_url,
get_model_role,
image_static_url,
)
class RoleImage(ChangeLoggingMixin, ExportTemplatesMixin, WebhooksMixin):
class Meta:
indexes = [
models.Index(fields=["content_type", "object_id"]),
]
objects: "models.Manager[RoleImage]"
image = models.CharField("Path within the netbox static directory", max_length=255)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField(null=True, blank=True)
__role: Optional[Role] = None
@property
def role(self) -> Role:
if self.__role:
return self.__role
model_class = self.content_type.model_class()
if not model_class:
raise ValueError(f"Invalid content type: {self.content_type}")
if model_class == DeviceRole:
device_role: DeviceRole = DeviceRole.objects.get(pk=self.object_id)
self.__role = Role(slug=device_role.slug, name=device_role.name)
return self.__role
self.__role = get_model_role(model_class)
return self.__role
def __str__(self):
return f"{self.role} - {self.image}"
def get_image(self) -> Path:
"""Get Icon
returns the model's image's absolute path in the filesystem
raises ValueError if the file cannot be found
"""
path = Path(settings.STATIC_ROOT) / self.image
if not path.exists():
raise ValueError(f"{self.role} path '{path}' does not exists")
return path
def get_default_image(self, dir: Path = CONF_IMAGE_DIR):
"""Get default image
will attempt to find image in given directory with any file extension,
otherwise will try to find a `role-unknown` image
fallback is `STATIC_ROOT/netbox_topology_views/img/role-unknown.png`
"""
if url := find_image_url(self.role.slug, dir):
return url
# fallback to default role unknown image
return image_static_url(IMAGE_DIR / "role-unknown.png")
def get_image_url(self, dir: Path = CONF_IMAGE_DIR) -> str:
try:
self.get_image()
except ValueError:
return self.get_default_image(dir)
return static(f"/{self.image}")
+3 -4
View File
@@ -1,9 +1,8 @@
from extras.plugins import PluginMenuButton, PluginMenuItem from extras.plugins import PluginMenuItem
from utilities.choices import ButtonColorChoices
menu_items = ( menu_items = (
PluginMenuItem(link="plugins:netbox_topology_views:home", link_text="Topology"),
PluginMenuItem( PluginMenuItem(
link='plugins:netbox_topology_views:home', link="plugins:netbox_topology_views:images", link_text="Topology Images"
link_text='Topology'
), ),
) )
@@ -1 +1 @@
#visgraph{height:70vh}html[data-netbox-color-mode=dark] #visgraph{background-color:#212529} #visgraph{height:70vh}html[data-netbox-color-mode=dark] #visgraph{background-color:#212529}.image-dropdown img{width:64px;height:64px}.image-dropdown-content{display:flex;flex-wrap:wrap;gap:.5rem;padding-inline:.5rem;width:50vw;max-width:32rem}.image-dropdown-content>img{cursor:pointer}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
(()=>{var l=(e,t,o)=>new Promise((n,r)=>{var c=s=>{try{i(o.next(s))}catch(a){r(a)}},m=s=>{try{i(o.throw(s))}catch(a){r(a)}},i=s=>s.done?n(s.value):Promise.resolve(s.value).then(c,m);i((o=o.apply(e,t)).next())});var d=e=>{if(!document.cookie)return;let t=null,o=document.cookie.split(";");for(let n=0;n<o.length;n++){let r=o[n].trim();if(r.substring(0,e.length+1)===e+"="){t=decodeURIComponent(r.substring(e.length+1));break}}return t};var u={success:e=>{let t=document.querySelector("#topology-plugin-success-toast");if(!t)return console.error("Could not find toast component!");let o=t.querySelector("span");o.textContent=e,new window.Toast(t).show()},error:e=>{let t=document.querySelector("#topology-plugin-error-toast");if(!t)return console.error("Could not find toast component!");let o=t.querySelector("span");o.textContent=e,new window.Toast(t).show()}};var g={},p=d("csrftoken");document.querySelector("form#images").addEventListener("submit",e=>l(void 0,null,function*(){e.preventDefault();try{let t=yield fetch("/api/plugins/netbox_topology_views/images/",{method:"POST",body:JSON.stringify(g),headers:{"X-CSRFToken":p,"Content-Type":"application/json"}});if(!t.ok)throw new Error(yield t.text());u.success("Saved settings")}catch(t){console.dir(t),u.error(t.message)}}));document.querySelectorAll("form#images .dropdown-menu img").forEach(e=>{e.addEventListener("click",t=>{var c;if(!(t.currentTarget instanceof HTMLElement))return;let{dataset:{role:o,image:n}}=t.currentTarget;g[o]=n;let r=(c=t.currentTarget.closest(".dropdown"))==null?void 0:c.querySelector(`#dropdownMenuButton${o}`);r&&(r.innerHTML=`<img src="${n}" />`)})});})();
@@ -0,0 +1,6 @@
{
"version": 3,
"sources": ["../../../static_dev/js/csrftoken.js", "../../../static_dev/js/toast.js", "../../../static_dev/js/images.js"],
"mappings": "mNAAO,GAAM,GAAY,AAAC,GAAS,CAC/B,GAAI,CAAC,SAAS,OAAQ,OAEtB,GAAI,GAAc,KACZ,EAAU,SAAS,OAAO,MAAM,KAEtC,OAAS,GAAI,EAAG,EAAI,EAAQ,OAAQ,IAAK,CACrC,GAAM,GAAS,EAAQ,GAAG,OAE1B,GAAI,EAAO,UAAU,EAAG,EAAK,OAAS,KAAO,EAAO,IAAK,CACrD,EAAc,mBAAmB,EAAO,UAAU,EAAK,OAAS,IAChE,OAIR,MAAO,ICfJ,GAAM,GAAQ,CACjB,QAAS,AAAC,GAAY,CAClB,GAAM,GAAK,SAAS,cAAc,kCAClC,GAAI,CAAC,EAAI,MAAO,SAAQ,MAAM,mCAC9B,GAAM,GAAU,EAAG,cAAc,QACjC,EAAQ,YAAc,EAEtB,AADc,GAAI,QAAO,MAAM,GACzB,QAEV,MAAO,AAAC,GAAY,CAChB,GAAM,GAAK,SAAS,cAAc,gCAClC,GAAI,CAAC,EAAI,MAAO,SAAQ,MAAM,mCAC9B,GAAM,GAAU,EAAG,cAAc,QACjC,EAAQ,YAAc,EAEtB,AADc,GAAI,QAAO,MAAM,GACzB,SCZd,GAAM,GAAU,GACV,EAAY,EAAU,aAE5B,SAAS,cAAc,eAAe,iBAAiB,SAAU,AAAO,GAAM,0BAC1E,EAAE,iBACF,GAAI,CACA,GAAM,GAAM,KAAM,OAAM,6CAA8C,CAClE,OAAQ,OACR,KAAM,KAAK,UAAU,GACrB,QAAS,CACL,cAAe,EACf,eAAgB,sBAIxB,GAAI,CAAC,EAAI,GAAI,KAAM,IAAI,OAAM,KAAM,GAAI,QACvC,EAAM,QAAQ,wBACT,EAAP,CACE,QAAQ,IAAI,GACZ,EAAM,MAAM,EAAI,aAIxB,SAAS,iBAAiB,kCAAkC,QAAQ,AAAC,GAAO,CACxE,EAAG,iBAAiB,QAAS,AAAC,GAAM,CA3BxC,MA4BQ,GAAI,CAAE,GAAE,wBAAyB,cAAc,OAC/C,GAAM,CACF,QAAS,CAAE,OAAM,UACjB,EAAE,cAEN,EAAQ,GAAQ,EAEhB,GAAM,GAAS,KAAE,cACZ,QAAQ,eADE,cAET,cAAc,sBAAsB,KAC1C,AAAI,GAAQ,GAAO,UAAY,aAAa",
"names": []
}
+75 -66
View File
@@ -1,87 +1,96 @@
const esbuild = require('esbuild'); const esbuild = require('esbuild')
const { sassPlugin } = require('esbuild-sass-plugin'); const { sassPlugin } = require('esbuild-sass-plugin')
const options = { const options = {
bundle: true, bundle: true,
minify: true, minify: true,
sourcemap: 'external', sourcemap: 'external',
sourcesContent: false, sourcesContent: false,
logLevel: 'error', logLevel: 'error'
}; }
const ARGS = process.argv.slice(2);
const ARGS = process.argv.slice(2)
const noCache = ARGS.includes('--no-cache')
async function bundleScripts() { async function bundleScripts() {
const entryPoints = { const entryPoints = {
'app': 'js/home.js' app: 'js/home.js',
}; images: 'js/images.js'
try { }
const result = await esbuild.build({
...options, try {
outdir: '../static/netbox_topology_views/js/', const result = await esbuild.build({
entryPoints, ...options,
target: 'es2016', outdir: '../static/netbox_topology_views/js/',
}); entryPoints,
if (result.errors.length === 0) { target: 'es2016'
for (const [targetName, sourceName] of Object.entries(entryPoints)) { })
const source = sourceName.split('/')[1]; if (result.errors.length !== 0) return
console.log(`✅ Bundled source file '${source}' to '${targetName}.js'`);
} for (const [targetName, sourceName] of Object.entries(entryPoints)) {
const source = sourceName.split('/').pop() // take last element
console.log(
`✅ Bundled source file '${source}' to '${targetName}.js'`
)
}
} catch (err) {
console.error(err)
} }
} catch (err) {
console.error(err);
}
} }
async function bundleStyles() { async function bundleStyles() {
try { try {
const entryPoints = { const entryPoints = {
'vendor': 'css/_external.scss', vendor: 'css/_external.scss',
'app': 'css/app.scss', app: 'css/app.scss'
}; }
const pluginOptions = { outputStyle: 'compressed' }; const pluginOptions = { outputStyle: 'compressed' }
// Allow cache disabling. // Allow cache disabling.
if (ARGS.includes('--no-cache')) { if (noCache) {
pluginOptions.cache = false; pluginOptions.cache = false
}
let result = await esbuild.build({
...options,
outdir: '../static/netbox_topology_views/css/',
// Disable sourcemaps for CSS/SCSS files, see #7068
sourcemap: false,
entryPoints,
plugins: [sassPlugin(pluginOptions)],
loader: {
'.eot': 'file',
'.woff': 'file',
'.woff2': 'file',
'.svg': 'file',
'.ttf': 'file',
},
});
if (result.errors.length === 0) {
for (const [targetName, sourceName] of Object.entries(entryPoints)) {
const source = sourceName.split('/')[1];
console.log(`✅ Bundled source file '${source}' to '${targetName}.css'`);
} }
}
} catch (err) {
console.error(err);
}
}
const result = await esbuild.build({
...options,
outdir: '../static/netbox_topology_views/css/',
// Disable sourcemaps for CSS/SCSS files, see #7068
sourcemap: false,
entryPoints,
plugins: [sassPlugin(pluginOptions)],
loader: {
'.eot': 'file',
'.woff': 'file',
'.woff2': 'file',
'.svg': 'file',
'.ttf': 'file'
}
})
if (result.errors.length === 0) {
for (const [targetName, sourceName] of Object.entries(
entryPoints
)) {
const source = sourceName.split('/')[1]
console.log(
`✅ Bundled source file '${source}' to '${targetName}.css'`
)
}
}
} catch (err) {
console.error(err)
}
}
async function bundleAll() { async function bundleAll() {
if (ARGS.includes('--styles')) { if (ARGS.includes('--styles')) {
// Only run style jobs. // Only run style jobs.
return await bundleStyles(); return await bundleStyles()
} else if (ARGS.includes('--scripts')) {
// Only run script jobs.
return await bundleScripts();
} }
await bundleStyles(); if (ARGS.includes('--scripts')) {
await bundleScripts(); // Only run script jobs.
return await bundleScripts()
}
await bundleStyles()
await bundleScripts()
} }
bundleAll(); bundleAll()
+23 -3
View File
@@ -1,7 +1,27 @@
#visgraph { #visgraph {
height: 70vh; height: 70vh;
} }
html[data-netbox-color-mode=dark] #visgraph { html[data-netbox-color-mode=dark] #visgraph {
background-color: #212529; background-color: #212529;
} }
.image-dropdown img {
width: 64px;
height: 64px;
}
.image-dropdown-content {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding-inline: 0.5rem;
width: 50vw;
max-width: 32rem;
> img {
cursor: pointer;
}
}
@@ -0,0 +1,17 @@
export const getCookie = (name) => {
if (!document.cookie) return
let cookieValue = null
const cookies = document.cookie.split(';')
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim()
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === name + '=') {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1))
break
}
}
return cookieValue
}
+113 -153
View File
@@ -1,17 +1,8 @@
import { DataSet } from "vis-data/esnext"; import { DataSet } from 'vis-data/esnext'
import { Network } from "vis-network/esnext"; import { Network } from 'vis-network/esnext'
//import 'vis-util'; import { getCookie } from './csrftoken.js'
const options = {
var graph = null;
var container = null;
var downloadButton = null;
const MIME_TYPE = "image/png";
var canvas = null;
var csrftoken = null;
var nodes = new DataSet();
var edges = new DataSet();
var options = {
interaction: { interaction: {
hover: true, hover: true,
hoverConnectedEdges: true, hoverConnectedEdges: true,
@@ -19,18 +10,22 @@ var options = {
}, },
nodes: { nodes: {
shape: 'image', shape: 'image',
brokenImage: '../../static/netbox_topology_views/img/role-unknown.png', brokenImage: brokenImage ?? '',
size: 35, size: 35,
font: { font: {
multi: 'md', multi: 'md',
face: 'helvetica', face: 'helvetica',
}, color:
document.documentElement.dataset.netboxColorMode === 'dark'
? '#fff'
: '#000'
}
}, },
edges: { edges: {
length: 100, length: 100,
width: 2, width: 2,
font: { font: {
face: 'helvetica', face: 'helvetica'
}, },
shadow: { shadow: {
enabled: true enabled: true
@@ -39,149 +34,114 @@ var options = {
physics: { physics: {
solver: 'forceAtlas2Based' solver: 'forceAtlas2Based'
} }
};
var coord_save_checkbox = null;
var htmlElement = null;
export function getCookie(name) {
var cookieValue = null;
if (document.cookie && document.cookie !== '') {
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
};
export function htmlTitle(html) {
container = document.createElement("div");
container.innerHTML = html;
return container;
};
export function addEdge(item) {
item.title = htmlTitle(item.title);
edges.add(item);
};
export function addNode(item) {
item.title = htmlTitle(item.title);
nodes.add(item);
} }
export function iniPlotboxIndex() { // Load CSRF token
csrftoken = getCookie('csrftoken'); const csrftoken = getCookie('csrftoken')
container = document.getElementById('visgraph');
htmlElement = document.getElementsByTagName("html")[0];
downloadButton = document.getElementById('btnDownloadImage');
handleLoadData();
btnFullView = document.getElementById('btnFullView');
coord_save_checkbox = document.getElementById('id_save_coords');
};
export function performGraphDownload() { // Render vis graph
var tempDownloadLink = document.createElement('a'); let graph = null // vis graph instance
var generatedImageUrl = canvas.toDataURL(MIME_TYPE);
tempDownloadLink.href = generatedImageUrl; const container = document.querySelector('#visgraph')
tempDownloadLink.download = "topology"; const coordSaveCheckbox = document.querySelector('#id_save_coords')
document.body.appendChild(tempDownloadLink); ;(function handleLoadData() {
tempDownloadLink.click(); if (!topologyData) return
document.body.removeChild(tempDownloadLink);
};
export function handleLoadData() { function htmlTitle(text) {
if (topology_data !== null) { const container = document.createElement('div')
container.innerHTML = text
if (htmlElement.dataset.netboxColorMode == "dark") { return container
options.nodes.font.color = "#fff";
}
graph = null;
nodes = new DataSet();
edges = new DataSet();
graph = new Network(container, { nodes: nodes, edges: edges }, options);
topology_data.edges.forEach(addEdge);
topology_data.nodes.forEach(addNode);
graph.fit();
canvas = document.getElementById('visgraph').getElementsByTagName('canvas')[0];
downloadButton.onclick = function(e) { performGraphDownload(); return false; };
graph.on("dragEnd", function (params) {
dragged = this.getPositions(params.nodes);
if (coord_save_checkbox.checked) {
if (Object.keys(dragged).length !== 0) {
for (dragged_device in dragged) {
var node_id = dragged_device;
var url = "/api/plugins/netbox_topology_views/save-coords/save_coords/";
var xhr = new XMLHttpRequest();
xhr.open("PATCH", url);
xhr.setRequestHeader('X-CSRFToken', csrftoken );
xhr.setRequestHeader("Accept", "application/json");
xhr.setRequestHeader("Content-Type", "application/json");
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
console.log(xhr.status);
}};
var data = JSON.stringify({
'node_id': node_id,
'x': dragged[node_id].x,
'y': dragged[node_id].y});
xhr.send(data);
}
}
}
});
graph.on("doubleClick", function (params) {
let selected_devices = params.nodes;
for (let selected_device in selected_devices) {
let url = ""
if(String(selected_devices[selected_device]).startsWith("c")) {
cid = selected_devices[selected_device].substring(1);
url = "/circuits/circuits/" + cid + "/";
}
else if (String(selected_devices[selected_device]).startsWith("p")) {
cid = selected_devices[selected_device].substring(1);
url = "/dcim/power-panels/" + cid + "/";
}
else if (String(selected_devices[selected_device]).startsWith("f")) {
cid = selected_devices[selected_device].substring(1);
url = "/dcim/power-feeds/" + cid + "/";
}
else {
url = "/dcim/devices/" + selected_devices[selected_device] + "/";
}
window.open(url, "_blank");
}
});
} }
};
export function load_doc() { const nodes = new DataSet(
if (document.readyState !== 'loading') { topologyData.nodes.map((node) => ({
iniPlotboxIndex(); ...node,
} else { title: htmlTitle(node.title)
document.addEventListener('DOMContentLoaded', iniPlotboxIndex); }))
} )
};
const edges = new DataSet(
topologyData.edges.map((node) => ({
...node,
title: htmlTitle(node.title)
}))
)
graph = new Network(container, { nodes, edges }, options)
graph.fit()
load_doc(); graph.on('dragEnd', (params) => {
if (!coordSaveCheckbox.checked) return
Promise.allSettled(
Object.entries(graph.getPositions(params.nodes)).map(
async ([nodeId, nodePosition]) => {
const res = await fetch(
'/api/plugins/netbox_topology_views/save-coords/',
{
method: 'PATCH',
headers: {
'X-CSRFToken': csrftoken,
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
node_id: nodeId,
x: nodePosition.x,
y: nodePosition.y
})
}
)
console.log(nodeId, res.status, res.statusText)
}
)
)
})
graph.on('doubleClick', (params) => {
params.nodes.forEach((node) => {
window.open(nodes.get(node).href, '_blank')
})
})
})()
// Download Graph
const MIME_TYPE = 'image/png'
const downloadButton = document.querySelector('#btnDownloadImage')
downloadButton.addEventListener('click', (e) => {
performGraphDownload()
})
function performGraphDownload() {
const canvas = container.querySelector('canvas')
const tempDownloadLink = document.createElement('a')
const generatedImageUrl = canvas.toDataURL(MIME_TYPE)
tempDownloadLink.href = generatedImageUrl
tempDownloadLink.download = 'topology'
document.body.appendChild(tempDownloadLink)
tempDownloadLink.click()
document.body.removeChild(tempDownloadLink)
}
// Theme switching
const observer = new MutationObserver((mutations) =>
mutations.forEach((mutation) => {
if (
!graph ||
mutation.type !== 'attributes' ||
mutation.attributeName !== 'data-netbox-color-mode' ||
!(mutation.target instanceof HTMLElement)
)
return
const { netboxColorMode } = mutation.target.dataset
options.nodes.font.color = netboxColorMode === 'dark' ? '#fff' : '#000'
graph.setOptions(options)
})
)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-netbox-color-mode']
})
@@ -0,0 +1,41 @@
import { getCookie } from './csrftoken.js'
import { toast } from './toast.js'
const mapping = {}
const csrftoken = getCookie('csrftoken')
document.querySelector('form#images').addEventListener('submit', async (e) => {
e.preventDefault()
try {
const res = await fetch('/api/plugins/netbox_topology_views/images/', {
method: 'POST',
body: JSON.stringify(mapping),
headers: {
'X-CSRFToken': csrftoken,
'Content-Type': 'application/json'
}
})
if (!res.ok) throw new Error(await res.text())
toast.success('Saved settings')
} catch (err) {
console.dir(err)
toast.error(err.message)
}
})
document.querySelectorAll('form#images .dropdown-menu img').forEach((el) => {
el.addEventListener('click', (e) => {
if (!(e.currentTarget instanceof HTMLElement)) return
const {
dataset: { role, image }
} = e.currentTarget
mapping[role] = image
const button = e.currentTarget
.closest('.dropdown')
?.querySelector(`#dropdownMenuButton${role}`)
if (button) button.innerHTML = `<img src="${image}" />`
})
})
@@ -0,0 +1,18 @@
export const toast = {
success: (message) => {
const el = document.querySelector('#topology-plugin-success-toast')
if (!el) return console.error('Could not find toast component!')
const content = el.querySelector('span')
content.textContent = message
const toast = new window.Toast(el)
toast.show()
},
error: (message) => {
const el = document.querySelector('#topology-plugin-error-toast')
if (!el) return console.error('Could not find toast component!')
const content = el.querySelector('span')
content.textContent = message
const toast = new window.Toast(el)
toast.show()
}
}
@@ -0,0 +1,46 @@
{% extends 'base/layout.html' %}
{% load buttons %}
{% load render_table from django_tables2 %}
{% load helpers %}
{% load static %}
{% block title %}Topology Views Images{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static 'netbox_topology_views/css/app.css' %}">
{% endblock %}
{% block content-wrapper %}
<form id="images" class="container py-4">
<div class="d-flex flex-wrap justify-content-center gap-3 mb-2">
{% for role in roles %}
<div class="d-flex flex-column align-items-center">
<div class="image-dropdown dropdown">
<button class="btn btn-secondary" type="button" id="dropdownMenuButton{{ role.id }}" data-bs-toggle="dropdown" aria-expanded="false">
{% if role.image %}
<img src="{{ role.image }}" />
{% else %}
Image
{% endif %}
</button>
<div class="dropdown-menu mt-2" aria-labelledby="dropdownMenuButton{{ role.id }}">
<div class="image-dropdown-content">
{% for image in images %}
<img src="{{ image.url }}" title="{{ image.title }}" width="64" data-role="{{ role.id }}" data-image="{{ image.url }}"/>
{% endfor %}
</div>
</div>
</div>
<span>{{ role.name }}</span>
</div>
{% endfor %}
</div>
<button class="btn btn-primary" type="submit">Save</button>
</form>
{% include 'netbox_topology_views/toasts.html' %}
{% endblock content-wrapper %}
{% block javascript %}
<script src="{% static 'netbox_topology_views/js/images.js' %}" defer></script>
{% endblock javascript %}
@@ -50,12 +50,8 @@
<div class="tab-pane show active" id="networks" role="tabpanel" aria-labelledby="network-tab"> <div class="tab-pane show active" id="networks" role="tabpanel" aria-labelledby="network-tab">
<div class="panel-body"> <div class="panel-body">
<div id="visgraph" class=""></div> <div id="visgraph"></div>
</div> </div>
<script type="text/javascript">
var topology_data = {{ topology_data | safe }};
</script>
</div> </div>
{% if filter_form %} {% if filter_form %}
@@ -69,5 +65,9 @@
{% endblock content-wrapper %} {% endblock content-wrapper %}
{% block javascript %} {% block javascript %}
<script src="{% static 'netbox_topology_views/js/app.js' %}"></script> <script type="text/javascript">
const brokenImage = '{{ broken_image }}';
const topologyData = {{ topology_data | safe }};
</script>
<script src="{% static 'netbox_topology_views/js/app.js' %}" defer></script>
{% endblock javascript %} {% endblock javascript %}
@@ -0,0 +1,44 @@
<div id="django-messages" class="toast-container">
<div
id="topology-plugin-error-toast"
class="toast align-items-center border-0 bg-danger hide"
role="alert"
aria-live="assertive"
aria-atomic="true"
data-bs-delay="2000"
>
<div class="d-flex">
<div class="toast-body">
😢
<span>Error</span>
</div>
<button
type="button"
class="btn-close me-2 m-auto"
data-bs-dismiss="toast"
aria-label="Close"
></button>
</div>
</div>
<div
id="topology-plugin-success-toast"
class="toast align-items-center border-0 bg-success hide"
role="alert"
aria-live="assertive"
aria-atomic="true"
data-bs-delay="2000"
>
<div class="d-flex">
<div class="toast-body">
👍
<span>Success</span>
</div>
<button
type="button"
class="btn-close me-2 m-auto"
data-bs-dismiss="toast"
aria-label="Close"
></button>
</div>
</div>
</div>
+4 -2
View File
@@ -1,10 +1,12 @@
from django.urls import path from django.urls import path
from django.views.generic.base import RedirectView
from . import views from . import views
# Define a list of URL patterns to be imported by NetBox. Each pattern maps a URL to # Define a list of URL patterns to be imported by NetBox. Each pattern maps a URL to
# a specific view so that it can be accessed by users. # a specific view so that it can be accessed by users.
urlpatterns = ( urlpatterns = (
path('', views.TopologyHomeView.as_view(), name='home'), path("", RedirectView.as_view(url="topology/", permanent=True)),
path("topology/", views.TopologyHomeView.as_view(), name="home"),
path("images/", views.TopologyImagesView.as_view(), name="images"),
) )
+86
View File
@@ -0,0 +1,86 @@
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Type
from django.conf import settings
from django.db.models import Model
from django.templatetags.static import static
from django.utils.text import camel_case_to_spaces, re_camel_case
IMAGE_DIR = Path(settings.STATIC_ROOT) / "netbox_topology_views/img"
CONF_IMAGE_DIR: Path = Path(settings.STATIC_ROOT) / settings.PLUGINS_CONFIG[
"netbox_topology_views"
]["static_image_directory"].removeprefix("/")
def image_static_url(path: Path) -> str:
return settings.BASE_PATH + static(
f"/{path.relative_to(Path(settings.STATIC_ROOT))}"
)
def get_image_from_url(url: str) -> str:
return url.removeprefix(settings.BASE_PATH + settings.STATIC_URL)
IMAGE_FILETYPES = (
"apng",
"avif",
"bmp",
"cur",
"gif",
"ico",
"jfif",
"jpeg",
"jpg",
"pjp",
"pjpeg",
"png",
"svg",
"webp",
)
def find_image_in_dir(glob: str, dir: Path):
return next(
(f for f in dir.glob(f"{glob}.*") if f.suffix.lstrip(".") in IMAGE_FILETYPES),
None,
)
@lru_cache(maxsize=50)
def find_image_url(glob: str, dir: Path = CONF_IMAGE_DIR):
"""
will attempt to find a file that matches glob in given directory with any file extension,
otherwise will try to find a `role-unknown` image
returns static file url
"""
if file := find_image_in_dir(glob, dir):
return image_static_url(file)
if glob != "role-unknown" and (file := find_image_in_dir("role-unknown", dir)):
return image_static_url(file)
if dir != IMAGE_DIR and (file := find_image_in_dir("role-unknown", IMAGE_DIR)):
return image_static_url(file)
return ""
@dataclass
class Role:
slug: str
name: str
def get_model_slug(model: Type[Model]):
return camel_case_to_spaces(model.__name__).replace(" ", "-")
def get_model_role(model: Type[Model]) -> Role:
return Role(
slug=get_model_slug(model),
name=re_camel_case.sub(r" \1", model.__name__),
)
+494 -195
View File
@@ -1,379 +1,678 @@
from django.shortcuts import render
from django.db.models import Q
from django.views.generic import View
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.conf import settings
from django.http import QueryDict
from django.http import HttpResponseRedirect
from .forms import DeviceFilterForm
from .filters import DeviceFilterSet
import json import json
from functools import reduce
from typing import DefaultDict, Dict, Optional, Union
from dcim.models import Device, CableTermination, DeviceRole, Interface, FrontPort, RearPort, PowerPanel, PowerFeed from circuits.models import Circuit, CircuitTermination
from circuits.models import CircuitTermination from dcim.models import (
from wireless.models import WirelessLink Cable,
CableTermination,
Device,
DeviceRole,
FrontPort,
Interface,
PowerFeed,
PowerPanel,
RearPort,
)
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q, QuerySet
from django.http import HttpRequest, HttpResponseRedirect, QueryDict
from django.shortcuts import render
from django.views.generic import View
from extras.models import Tag from extras.models import Tag
from wireless.models import WirelessLink
supported_termination_types = ["interface", "front port", "rear port", "power outlet", "power port", "console port", "console server port"] from netbox_topology_views.filters import DeviceFilterSet
from netbox_topology_views.forms import DeviceFilterForm
from netbox_topology_views.models import RoleImage
from netbox_topology_views.utils import (
CONF_IMAGE_DIR,
find_image_url,
get_model_role,
get_model_slug,
image_static_url,
)
def create_node(device, save_coords, circuit = None, powerpanel = None, powerfeed= None): supported_termination_types = [
"interface",
"front port",
"rear port",
"power outlet",
"power port",
"console port",
"console server port",
]
def get_image_for_entity(entity: Union[Device, Circuit, PowerPanel, PowerFeed]):
is_device = isinstance(entity, Device)
query = (
{"object_id": entity.device_role_id}
if is_device
else {"content_type_id": ContentType.objects.get_for_model(entity).pk}
)
try:
return RoleImage.objects.get(**query).get_image_url()
except RoleImage.DoesNotExist:
return find_image_url(
entity.device_role.slug if is_device else get_model_slug(entity.__class__)
)
def create_node(
device: Union[Device, Circuit, PowerPanel, PowerFeed], save_coords: bool
):
node = {} node = {}
node_content = "" node_content = ""
if circuit: if isinstance(device, Circuit):
dev_name = "Circuit " + str(device.cid) dev_name = f"Circuit {device.cid}"
node["image"] = "../../static/netbox_topology_views/img/circuit.png" node["id"] = f"c{device.pk}"
node["id"] = "c{}".format(device.id)
if device.provider is not None: if device.provider is not None:
node_content += "<tr><th>Provider: </th><td>" + device.provider.name + "</td></tr>" node_content += (
f"<tr><th>Provider: </th><td>{device.provider.name}</td></tr>"
)
if device.type is not None: if device.type is not None:
node_content += "<tr><th>Type: </th><td>" + device.type.name + "</td></tr>" node_content += f"<tr><th>Type: </th><td>{device.type.name}</td></tr>"
elif powerpanel: elif isinstance(device, PowerPanel):
dev_name = "Power Panel " + str(device.id) dev_name = f"Power Panel {device.pk}"
node["image"] = "../../static/netbox_topology_views/img/power-panel.png" node["id"] = f"p{device.pk}"
node["id"] = "p{}".format(device.id)
if device.site is not None: if device.site is not None:
node_content += "<tr><th>Site: </th><td>" + device.site.name + "</td></tr>" node_content += f"<tr><th>Site: </th><td>{device.site.name}</td></tr>"
if device.location is not None: if device.location is not None:
node_content += "<tr><th>Location: </th><td>" + device.location.name + "</td></tr>" node_content += (
elif powerfeed: f"<tr><th>Location: </th><td>{device.location.name}</td></tr>"
dev_name = "Power Feed " + str(device.id) )
node["image"] = "../../static/netbox_topology_views/img/power-feed.png" elif isinstance(device, PowerFeed):
node["id"] = "f{}".format(device.id) dev_name = f"Power Feed {device.pk}"
node["id"] = f"f{device.pk}"
if device.power_panel is not None: if device.power_panel is not None:
node_content += "<tr><th>Power Panel: </th><td>" + device.power_panel.name + "</td></tr>" node_content += (
f"<tr><th>Power Panel: </th><td>{device.power_panel.name}</td></tr>"
)
if device.type is not None: if device.type is not None:
node_content += "<tr><th>Type: </th><td>" + device.type + "</td></tr>" node_content += f"<tr><th>Type: </th><td>{device.type}</td></tr>"
if device.supply is not None: if device.supply is not None:
node_content += "<tr><th>Supply: </th><td>" + device.supply + "</td></tr>" node_content += f"<tr><th>Supply: </th><td>{device.supply}</td></tr>"
if device.phase is not None: if device.phase is not None:
node_content += "<tr><th>Phase: </th><td>" + device.phase + "</td></tr>" node_content += f"<tr><th>Phase: </th><td>{device.phase}</td></tr>"
if device.amperage is not None: if device.amperage is not None:
node_content += "<tr><th>Amperage: </th><td>" + str(device.amperage )+ "</td></tr>" node_content += f"<tr><th>Amperage: </th><td>{device.amperage}</td></tr>"
if device.voltage is not None: if device.voltage is not None:
node_content += "<tr><th>Voltage: </th><td>" + str(device.voltage) + "</td></tr>" node_content += f"<tr><th>Voltage: </th><td>{device.voltage}</td></tr>"
else: else:
dev_name = device.name dev_name = device.name
if dev_name is None: if dev_name is None:
dev_name = "device name unknown" dev_name = "device name unknown"
if device.device_type is not None: if device.device_type is not None:
node_content += "<tr><th>Type: </th><td>" + device.device_type.model + "</td></tr>" node_content += (
f"<tr><th>Type: </th><td>{device.device_type.model}</td></tr>"
)
if device.device_role.name is not None: if device.device_role.name is not None:
node_content += "<tr><th>Role: </th><td>" + device.device_role.name + "</td></tr>" node_content += (
f"<tr><th>Role: </th><td>{device.device_role.name}</td></tr>"
)
if device.serial != "": if device.serial != "":
node_content += "<tr><th>Serial: </th><td>" + device.serial + "</td></tr>" node_content += f"<tr><th>Serial: </th><td>{device.serial}</td></tr>"
if device.primary_ip is not None: if device.primary_ip is not None:
node_content += "<tr><th>IP Address: </th><td>" + str(device.primary_ip.address) + "</td></tr>" node_content += (
f"<tr><th>IP Address: </th><td>{device.primary_ip.address}</td></tr>"
)
if device.site is not None: if device.site is not None:
node_content += "<tr><th>Site: </th><td>" + device.site.name + "</td></tr>" node_content += f"<tr><th>Site: </th><td>{device.site.name}</td></tr>"
if device.location is not None: if device.location is not None:
node_content += "<tr><th>Location: </th><td>" + device.location.name + "</td></tr>" node_content += (
f"<tr><th>Location: </th><td>{device.location.name}</td></tr>"
)
if device.rack is not None: if device.rack is not None:
node_content += "<tr><th>Rack: </th><td>" + device.rack.name + "</td></tr>" node_content += f"<tr><th>Rack: </th><td>{device.rack.name}</td></tr>"
if device.position is not None: if device.position is not None:
if device.face is not None: if device.face is not None:
node_content += "<tr><th>Position: </th><td> {} ({}) </td></tr>".format(device.position, device.face) node_content += f"<tr><th>Position: </th><td>{device.position} ({device.face})</td></tr>"
else: else:
node_content += "<tr><th>Position: </th><td>" + device.position + "</td></tr>" node_content += (
f"<tr><th>Position: </th><td>{device.position}</td></tr>"
)
node["id"] = device.id node["id"] = device.pk
if device.device_role.slug in settings.PLUGINS_CONFIG["netbox_topology_views"]["device_img"]:
node["image"] = "../../static/netbox_topology_views/img/" + device.device_role.slug + ".png"
else:
node["image"] = "../../static/netbox_topology_views/img/role-unknown.png"
if device.device_role.color != "": if device.device_role.color != "":
node["color.border"] = "#" + device.device_role.color node["color.border"] = "#" + device.device_role.color
dev_title = "<table><tbody> %s</tbody></table>" % (node_content) dev_title = "<table><tbody> %s</tbody></table>" % (node_content)
node["title"] = dev_title node["title"] = dev_title
node["name"] = dev_name node["name"] = dev_name
node["label"] = dev_name node["label"] = dev_name
node["shape"] = "image" node["shape"] = "image"
node["href"] = device.get_absolute_url()
node["image"] = get_image_for_entity(device)
node["physics"] = True node["physics"] = True
if "coordinates" in device.custom_field_data: if "coordinates" in device.custom_field_data:
if device.custom_field_data["coordinates"] is not None: if device.custom_field_data["coordinates"] is not None:
if ";" in device.custom_field_data["coordinates"]: if ";" in device.custom_field_data["coordinates"]:
cords = device.custom_field_data["coordinates"].split(";") cords = device.custom_field_data["coordinates"].split(";")
node["x"] = int(cords[0]) node["x"] = int(cords[0])
node["y"] = int(cords[1]) node["y"] = int(cords[1])
node["physics"] = False node["physics"] = False
else: elif save_coords:
if save_coords: node["physics"] = False
node["physics"] = False
else:
node["physics"] = True
return node return node
def create_edge(edge_id, termination_a, termination_b, circuit = None, cable = None, wireless = None, power=None):
cable_a_name = "device A name unknown" if termination_a["termination_name"] is None else termination_a["termination_name"] def create_edge(
cable_a_dev_name = "device A name unknown" if termination_a["termination_device_name"] is None else termination_a["termination_device_name"] edge_id: int,
cable_b_name= "device A name unknown" if termination_b["termination_name"] is None else termination_b["termination_name"] termination_a: Dict,
cable_b_dev_name = "cable B name unknown" if termination_b["termination_device_name"] is None else termination_b["termination_device_name"] termination_b: Dict,
circuit: Optional[Dict] = None,
cable: Optional[Cable] = None,
wireless: Optional[Dict] = None,
power: Optional[bool] = None,
):
cable_a_name = (
"device A name unknown"
if termination_a["termination_name"] is None
else termination_a["termination_name"]
)
cable_a_dev_name = (
"device A name unknown"
if termination_a["termination_device_name"] is None
else termination_a["termination_device_name"]
)
cable_b_name = (
"device A name unknown"
if termination_b["termination_name"] is None
else termination_b["termination_name"]
)
cable_b_dev_name = (
"cable B name unknown"
if termination_b["termination_device_name"] is None
else termination_b["termination_device_name"]
)
edge = {} edge = {}
edge["id"] = edge_id edge["id"] = edge_id
edge["from"] = termination_a["device_id"] edge["from"] = termination_a["device_id"]
edge["to"] = termination_b["device_id"] edge["to"] = termination_b["device_id"]
title = "Cable"
if circuit is not None: if circuit is not None:
edge["dashes"] = True edge["dashes"] = True
edge["title"] = "Circuit provider: " + circuit["provider_name"] + "<br>" title = f"Circuit provider: {circuit['provider_name']}<br>Termination"
edge["title"] += "Termination between <br>"
edge["title"] += cable_b_dev_name + " [" + cable_b_name + "]<br>"
edge["title"] += cable_a_dev_name + " [" + cable_a_name + "]"
elif wireless is not None: elif wireless is not None:
edge["dashes"] = [2, 10, 2, 10] edge["dashes"] = [2, 10, 2, 10]
edge["title"] = "Wireless Connection between <br> " + cable_a_dev_name + " [" + cable_a_name + "]<br>" + cable_b_dev_name + " [" + cable_b_name + "]" title = "Wireless Connection"
elif power is not None: elif power is not None:
edge["dashes"] = [5, 5, 3, 3] edge["dashes"] = [5, 5, 3, 3]
edge["title"] = "Power Connection between <br> " + cable_a_dev_name + " [" + cable_a_name + "]<br>" + cable_b_dev_name + " [" + cable_b_name + "]" title = "Power Connection"
else:
edge["title"] = "Cable between <br> " + cable_a_dev_name + " [" + cable_a_name + "]<br>" + cable_b_dev_name + " [" + cable_b_name + "]" edge[
"title"
] = f"{title} between<br>{cable_a_dev_name} [{cable_a_name}]<br>{cable_b_dev_name} [{cable_b_name}]"
if cable is not None and cable.color != "": if cable is not None and cable.color != "":
edge["color"] = "#" + cable.color edge["color"] = "#" + cable.color
return edge return edge
def create_circuit_termination(termination): def create_circuit_termination(termination):
if isinstance(termination, CircuitTermination): if isinstance(termination, CircuitTermination):
return { "termination_name": termination.circuit.provider.name, "termination_device_name": termination.circuit.cid, "device_id": "c{}".format(termination.circuit.id) } return {
if isinstance(termination, Interface) or isinstance(termination, FrontPort) or isinstance(termination, RearPort): "termination_name": termination.circuit.provider.name,
return { "termination_name": termination.name, "termination_device_name": termination.device.name, "device_id": termination.device.id } "termination_device_name": termination.circuit.cid,
"device_id": "c{}".format(termination.circuit.pk),
}
if (
isinstance(termination, Interface)
or isinstance(termination, FrontPort)
or isinstance(termination, RearPort)
):
return {
"termination_name": termination.name,
"termination_device_name": termination.device.name,
"device_id": termination.device.pk,
}
return None return None
def get_topology_data(queryset, hide_unconnected, save_coords, show_circuit, show_power):
def get_topology_data(
queryset: QuerySet,
hide_unconnected: bool,
save_coords: bool,
show_circuit: bool,
show_power: bool,
):
if not queryset:
return None
nodes_devices = {} nodes_devices = {}
edges = [] edges = []
nodes = [] nodes = []
edge_ids = 0 edge_ids = 0
nodes_circuits = {} nodes_circuits: Dict[int, Circuit] = {}
nodes_powerpanel = {} nodes_powerpanel: Dict[int, PowerPanel] = {}
nodes_powerfeed = {} nodes_powerfeed: Dict[int, PowerFeed] = {}
nodes_provider_networks = {} nodes_provider_networks = {}
cable_ids = {} cable_ids = DefaultDict(dict)
if not queryset: ignore_cable_type = settings.PLUGINS_CONFIG["netbox_topology_views"][
return None "ignore_cable_type"
]
ignore_cable_type = settings.PLUGINS_CONFIG["netbox_topology_views"]["ignore_cable_type"] device_ids = [d.pk for d in queryset]
site_ids = [d.site_id for d in queryset]
device_ids = [d.id for d in queryset]
site_ids = [d.site.id for d in queryset]
if show_circuit: if show_circuit:
circuits = CircuitTermination.objects.filter( Q(site_id__in=site_ids) | Q( provider_network__isnull=False) ).prefetch_related("provider_network", "circuit") circuit_terminations = CircuitTermination.objects.filter(
for circuit in circuits: Q(site_id__in=site_ids) | Q(provider_network__isnull=False)
if not hide_unconnected and circuit.circuit.id not in nodes_circuits: ).prefetch_related("provider_network", "circuit")
nodes_circuits[circuit.circuit.id] = circuit.circuit for circuit_termination in circuit_terminations:
circuit_termination: CircuitTermination
if (
not hide_unconnected
and circuit_termination.circuit_id not in nodes_circuits
):
nodes_circuits[
circuit_termination.circuit.pk
] = circuit_termination.circuit
termination_a = {} termination_a = {}
termination_b = {} termination_b = {}
circuit_model = {} circuit_model = {}
if circuit.cable is not None: if circuit_termination.cable is not None:
termination_a = create_circuit_termination(circuit.cable.a_terminations[0]) termination_a = create_circuit_termination(
termination_b = create_circuit_termination(circuit.cable.b_terminations[0]) circuit_termination.cable.a_terminations[0]
elif circuit.provider_network is not None: )
if circuit.provider_network.id not in nodes_provider_networks: termination_b = create_circuit_termination(
nodes_provider_networks[circuit.provider_network.id] = circuit.provider_network circuit_termination.cable.b_terminations[0]
)
elif circuit_termination.provider_network is not None:
if (
circuit_termination.provider_network_id
not in nodes_provider_networks
):
nodes_provider_networks[
circuit_termination.provider_network.pk
] = circuit_termination.provider_network
if bool(termination_a) and bool(termination_b): if bool(termination_a) and bool(termination_b):
circuit_model = {"provider_name": circuit.circuit.provider.name} circuit_model = {
"provider_name": circuit_termination.circuit.provider.name
}
edge_ids += 1 edge_ids += 1
edges.append(create_edge(edge_id=edge_ids,circuit=circuit_model, termination_a=termination_a, termination_b=termination_b)) edges.append(
create_edge(
edge_id=edge_ids,
circuit=circuit_model,
termination_a=termination_a,
termination_b=termination_b,
)
)
circuit_has_connections = False circuit_has_connections = False
for termination in [circuit.cable.a_terminations[0], circuit.cable.b_terminations[0]]: for termination in [
circuit_termination.cable.a_terminations[0],
circuit_termination.cable.b_terminations[0],
]:
if not isinstance(termination, CircuitTermination): if not isinstance(termination, CircuitTermination):
if termination.device.id not in nodes_devices and termination.device.id in device_ids: if (
nodes_devices[termination.device.id] = termination.device termination.device_id not in nodes_devices
and termination.device_id in device_ids
):
nodes_devices[termination.device_id] = termination.device
circuit_has_connections = True circuit_has_connections = True
else: else:
if termination.device.id in device_ids: if termination.device_id in device_ids:
circuit_has_connections = True circuit_has_connections = True
if circuit_has_connections and hide_unconnected: if circuit_has_connections and hide_unconnected:
if circuit.circuit.id not in nodes_circuits: if circuit_termination.circuit_id not in nodes_circuits:
nodes_circuits[circuit.circuit.id] = circuit.circuit nodes_circuits[
circuit_termination.circuit.pk
] = circuit_termination.circuit
for d in nodes_circuits.values(): for d in nodes_circuits.values():
nodes.append(create_node(d, save_coords, circuit=True)) nodes.append(create_node(d, save_coords))
links: QuerySet[CableTermination] = CableTermination.objects.filter(
Q(_device_id__in=device_ids)
).select_related("termination_type")
wlan_links: QuerySet[WirelessLink] = WirelessLink.objects.filter(
Q(_interface_a_device_id__in=device_ids)
& Q(_interface_b_device_id__in=device_ids)
)
links = CableTermination.objects.filter( Q(_device_id__in=device_ids) ).select_related("termination_type")
wlan_links = WirelessLink.objects.filter( Q(_interface_a_device_id__in=device_ids) & Q(_interface_b_device_id__in=device_ids))
if show_power: if show_power:
power_panels = PowerPanel.objects.filter( Q (site_id__in=site_ids)) power_panels_ids = PowerPanel.objects.filter(
power_panels_ids = [d.id for d in power_panels] Q(site_id__in=site_ids)
power_feeds = PowerFeed.objects.filter( Q (power_panel_id__in=power_panels_ids)) ).values_list("pk", flat=True)
power_feeds: QuerySet[PowerFeed] = PowerFeed.objects.filter(
Q(power_panel_id__in=power_panels_ids)
)
for power_feed in power_feeds: for power_feed in power_feeds:
if not hide_unconnected or (hide_unconnected and power_feed.cable_id is not None): if not hide_unconnected or (
if power_feed.power_panel.id not in nodes_powerpanel: hide_unconnected and power_feed.cable_id is not None
nodes_powerpanel[power_feed.power_panel.id] = power_feed.power_panel ):
if power_feed.power_panel_id not in nodes_powerpanel:
nodes_powerpanel[power_feed.power_panel.pk] = power_feed.power_panel
power_link_name = "" power_link_name = ""
if power_feed.id not in nodes_powerfeed: if power_feed.pk not in nodes_powerfeed:
if hide_unconnected: if hide_unconnected:
if power_feed.link_peers[0].device.id in device_ids: if power_feed.link_peers[0].device_id in device_ids:
nodes_powerfeed[power_feed.id] = power_feed nodes_powerfeed[power_feed.pk] = power_feed
power_link_name =power_feed.link_peers[0].name power_link_name = power_feed.link_peers[0].name
else: else:
nodes_powerfeed[power_feed.id] = power_feed nodes_powerfeed[power_feed.pk] = power_feed
edge_ids += 1 edge_ids += 1
termination_a = { "termination_name": power_feed.power_panel.name, "termination_device_name": "", "device_id": "p{}".format(power_feed.power_panel.id) } termination_a = {
termination_b = { "termination_name": power_feed.name, "termination_device_name": power_link_name, "device_id": "f{}".format(power_feed.id) } "termination_name": power_feed.power_panel.name,
edges.append(create_edge(edge_id=edge_ids, termination_a=termination_a, termination_b=termination_b, power=True)) "termination_device_name": "",
"device_id": f"p{power_feed.power_panel_id}",
}
termination_b = {
"termination_name": power_feed.name,
"termination_device_name": power_link_name,
"device_id": f"f{power_feed.pk}",
}
edges.append(
create_edge(
edge_id=edge_ids,
termination_a=termination_a,
termination_b=termination_b,
power=True,
)
)
if power_feed.cable_id is not None: if power_feed.cable_id is not None:
if power_feed.cable.id not in cable_ids: cable_ids[power_feed.cable_id][power_feed.cable_end] = termination_b
cable_ids[power_feed.cable.id] = {}
cable_ids[power_feed.cable.id][power_feed.cable_end] = termination_b
for d in nodes_powerfeed.values(): for d in nodes_powerfeed.values():
nodes.append(create_node(d, save_coords, powerfeed = True)) nodes.append(create_node(d, save_coords))
for d in nodes_powerpanel.values(): for d in nodes_powerpanel.values():
nodes.append(create_node(d, save_coords, powerpanel = True)) nodes.append(create_node(d, save_coords))
for link in links: for link in links:
if link.termination_type.name in ignore_cable_type : if link.termination_type.name in ignore_cable_type:
continue continue
#Normal device cables # Normal device cables
if link.termination_type.name in supported_termination_types: if link.termination_type.name in supported_termination_types:
complete_link = False complete_link = False
if link.cable_end == "A": if link.cable_end == "A":
if link.cable.id not in cable_ids: if link.cable_id not in cable_ids:
cable_ids[link.cable.id] = {} cable_ids[link.cable_id] = {}
else: else:
if 'B' in cable_ids[link.cable.id]: if "B" in cable_ids[link.cable_id]:
if cable_ids[link.cable.id]['B'] is not None: if cable_ids[link.cable_id]["B"] is not None:
complete_link = True complete_link = True
elif link.cable_end == "B": elif link.cable_end == "B":
if link.cable.id not in cable_ids: if link.cable_id not in cable_ids:
cable_ids[link.cable.id] = {} cable_ids[link.cable_id] = {}
else: else:
if 'A' in cable_ids[link.cable.id]: if "A" in cable_ids[link.cable_id]:
if cable_ids[link.cable.id]['A'] is not None: if cable_ids[link.cable_id]["A"] is not None:
complete_link = True complete_link = True
else: else:
print("Unkown cable end") print("Unkown cable end")
cable_ids[link.cable.id][link.cable_end] = link cable_ids[link.cable_id][link.cable_end] = link
if complete_link: if complete_link:
edge_ids += 1 edge_ids += 1
if isinstance(cable_ids[link.cable.id]["B"], CableTermination): if isinstance(cable_ids[link.cable_id]["B"], CableTermination):
if cable_ids[link.cable.id]["B"]._device_id not in nodes_devices: if cable_ids[link.cable_id]["B"]._device_id not in nodes_devices:
nodes_devices[cable_ids[link.cable.id]["B"]._device_id] = cable_ids[link.cable.id]["B"].termination.device nodes_devices[
termination_b = { "termination_name": cable_ids[link.cable.id]["B"].termination.name, "termination_device_name": cable_ids[link.cable.id]["B"].termination.device.name, "device_id": cable_ids[link.cable.id]["B"].termination.device.id } cable_ids[link.cable_id]["B"]._device_id
] = cable_ids[link.cable_id]["B"].termination.device
termination_b = {
"termination_name": cable_ids[link.cable_id][
"B"
].termination.name,
"termination_device_name": cable_ids[link.cable_id][
"B"
].termination.device.name,
"device_id": cable_ids[link.cable_id][
"B"
].termination.device_id,
}
else: else:
termination_b = cable_ids[link.cable.id]["B"] termination_b = cable_ids[link.cable_id]["B"]
if isinstance(cable_ids[link.cable.id]["A"], CableTermination): if isinstance(cable_ids[link.cable_id]["A"], CableTermination):
if cable_ids[link.cable.id]["A"]._device_id not in nodes_devices: if cable_ids[link.cable_id]["A"]._device_id not in nodes_devices:
nodes_devices[cable_ids[link.cable.id]["A"]._device_id] = cable_ids[link.cable.id]["A"].termination.device nodes_devices[
termination_a = { "termination_name": cable_ids[link.cable.id]["A"].termination.name, "termination_device_name": cable_ids[link.cable.id]["A"].termination.device.name, "device_id": cable_ids[link.cable.id]["A"].termination.device.id } cable_ids[link.cable_id]["A"]._device_id
] = cable_ids[link.cable_id]["A"].termination.device
termination_a = {
"termination_name": cable_ids[link.cable_id][
"A"
].termination.name,
"termination_device_name": cable_ids[link.cable_id][
"A"
].termination.device.name,
"device_id": cable_ids[link.cable_id][
"A"
].termination.device_id,
}
else: else:
termination_a = cable_ids[link.cable.id]["A"] termination_a = cable_ids[link.cable_id]["A"]
edges.append(create_edge(edge_id=edge_ids, cable=link.cable, termination_a=termination_a, termination_b=termination_b)) edges.append(
create_edge(
edge_id=edge_ids,
cable=link.cable,
termination_a=termination_a,
termination_b=termination_b,
)
)
for wlan_link in wlan_links: for wlan_link in wlan_links:
if wlan_link.interface_a.device.id not in nodes_devices: if wlan_link.interface_a.device_id not in nodes_devices:
nodes_devices[wlan_link.interface_a.device.id] = wlan_link.interface_a.device nodes_devices[
if wlan_link.interface_b.device.id not in nodes_devices: wlan_link.interface_a.device.pk
nodes_devices[wlan_link.interface_b.device.id] = wlan_link.interface_b.device ] = wlan_link.interface_a.device
if wlan_link.interface_b.device_id not in nodes_devices:
termination_a = {"termination_name": wlan_link.interface_a.name, "termination_device_name": wlan_link.interface_a.device.name, "device_id": wlan_link.interface_a.device.id} nodes_devices[
termination_b = {"termination_name": wlan_link.interface_b.name, "termination_device_name": wlan_link.interface_b.device.name, "device_id": wlan_link.interface_b.device.id} wlan_link.interface_b.device.pk
wireless = {"ssid": wlan_link.ssid } ] = wlan_link.interface_b.device
termination_a = {
"termination_name": wlan_link.interface_a.name,
"termination_device_name": wlan_link.interface_a.device.name,
"device_id": wlan_link.interface_a.device_id,
}
termination_b = {
"termination_name": wlan_link.interface_b.name,
"termination_device_name": wlan_link.interface_b.device.name,
"device_id": wlan_link.interface_b.device_id,
}
wireless = {"ssid": wlan_link.ssid}
edge_ids += 1 edge_ids += 1
edges.append(create_edge(edge_id=edge_ids, termination_a=termination_a, termination_b=termination_b,wireless=wireless)) edges.append(
create_edge(
edge_id=edge_ids,
termination_a=termination_a,
termination_b=termination_b,
wireless=wireless,
)
)
for qs_device in queryset: for qs_device in queryset:
if qs_device.id not in nodes_devices and not hide_unconnected: if qs_device.pk not in nodes_devices and not hide_unconnected:
nodes_devices[qs_device.id] = qs_device nodes_devices[qs_device.pk] = qs_device
results = {} results = {}
for d in nodes_devices.values(): for d in nodes_devices.values():
nodes.append(create_node(d, save_coords)) nodes.append(create_node(d, save_coords))
results["nodes"] = nodes results["nodes"] = nodes
results["edges"] = edges results["edges"] = edges
return results return results
class TopologyHomeView(PermissionRequiredMixin, View): class TopologyHomeView(PermissionRequiredMixin, View):
permission_required = ("dcim.view_site", "dcim.view_device") permission_required = ("dcim.view_site", "dcim.view_device")
""" """
Show the home page Show the home page
""" """
def get(self, request): def get(self, request):
self.filterset = DeviceFilterSet self.filterset = DeviceFilterSet
self.queryset = Device.objects.all().select_related("device_type", "device_role") self.queryset = Device.objects.all().select_related(
"device_type", "device_role"
)
self.queryset = self.filterset(request.GET, self.queryset).qs self.queryset = self.filterset(request.GET, self.queryset).qs
topo_data = None topo_data = None
if request.GET: if request.GET:
save_coords = False save_coords = False
if 'save_coords' in request.GET: if "save_coords" in request.GET:
if request.GET["save_coords"] == "on": if request.GET["save_coords"] == "on":
save_coords = True save_coords = True
hide_unconnected = False hide_unconnected = False
if "hide_unconnected" in request.GET: if "hide_unconnected" in request.GET:
if request.GET["hide_unconnected"] == "on" : if request.GET["hide_unconnected"] == "on":
hide_unconnected = True hide_unconnected = True
show_power = False show_power = False
if "show_power" in request.GET: if "show_power" in request.GET:
if request.GET["show_power"] == "on" : if request.GET["show_power"] == "on":
show_power = True show_power = True
show_circuit = False show_circuit = False
if "show_circuit" in request.GET: if "show_circuit" in request.GET:
if request.GET["show_circuit"] == "on" : if request.GET["show_circuit"] == "on":
show_circuit = True show_circuit = True
if "draw_init" in request.GET: if "draw_init" in request.GET:
if request.GET["draw_init"].lower() == "true": if request.GET["draw_init"].lower() == "true":
topo_data = get_topology_data(self.queryset, hide_unconnected, save_coords, show_circuit, show_power) topo_data = get_topology_data(
self.queryset,
hide_unconnected,
save_coords,
show_circuit,
show_power,
)
else: else:
topo_data = get_topology_data(self.queryset, hide_unconnected, save_coords, show_circuit, show_power) topo_data = get_topology_data(
self.queryset,
hide_unconnected,
save_coords,
show_circuit,
show_power,
)
else: else:
preselected_device_roles = settings.PLUGINS_CONFIG["netbox_topology_views"]["preselected_device_roles"] preselected_device_roles = settings.PLUGINS_CONFIG["netbox_topology_views"][
preselected_tags = settings.PLUGINS_CONFIG["netbox_topology_views"]["preselected_tags"] "preselected_device_roles"
always_save_coordinates = bool(settings.PLUGINS_CONFIG["netbox_topology_views"]["always_save_coordinates"]) ]
preselected_tags = settings.PLUGINS_CONFIG["netbox_topology_views"][
"preselected_tags"
]
always_save_coordinates = bool(
settings.PLUGINS_CONFIG["netbox_topology_views"][
"always_save_coordinates"
]
)
q_device_role_id = DeviceRole.objects.filter(name__in=preselected_device_roles).values_list("id", flat=True) q_device_role_id = DeviceRole.objects.filter(
q_tags = Tag.objects.filter(name__in=preselected_tags).values_list("name", flat=True) name__in=preselected_device_roles
).values_list("id", flat=True)
q_tags = Tag.objects.filter(name__in=preselected_tags).values_list(
"name", flat=True
)
q = QueryDict(mutable=True) q = QueryDict(mutable=True)
q.setlist("device_role_id", list(q_device_role_id)) q.setlist("device_role_id", list(q_device_role_id))
q.setlist("tag", list(q_tags)) q.setlist("tag", list(q_tags))
q["draw_init"] = settings.PLUGINS_CONFIG["netbox_topology_views"]["draw_default_layout"] q["draw_init"] = settings.PLUGINS_CONFIG["netbox_topology_views"][
"draw_default_layout"
]
if always_save_coordinates: if always_save_coordinates:
q["save_coords"] = "on" q["save_coords"] = "on"
query_string = q.urlencode() query_string = q.urlencode()
return HttpResponseRedirect(request.path + "?" + query_string) return HttpResponseRedirect(f"{request.path}?{query_string}")
return render(request, "netbox_topology_views/index.html" , { return render(
"filter_form": DeviceFilterForm(request.GET, label_suffix=""), request,
"topology_data": json.dumps(topo_data) "netbox_topology_views/index.html",
} {
"filter_form": DeviceFilterForm(request.GET, label_suffix=""),
"topology_data": json.dumps(topo_data),
"broken_image": find_image_url("role-unknown"),
},
)
CONFIG = settings.PLUGINS_CONFIG["netbox_topology_views"]
ADDITIONAL_ROLES = (PowerPanel, PowerFeed, Circuit)
class TopologyImagesView(PermissionRequiredMixin, View):
permission_required = (
"dcim.view_site",
"dcim.view_device_role",
"dcim.add_device_role",
"dcim.change_device_role",
)
def get(self, request: HttpRequest):
images = [
{"url": image_static_url(image), "title": image.stem}
for image in CONF_IMAGE_DIR.iterdir()
]
roles = reduce(
lambda acc, cur: {
**acc,
cur.name: {
"id": cur.pk,
"name": cur.name,
"slug": cur.slug,
"image": find_image_url(cur.slug),
},
},
DeviceRole.objects.all(),
dict(),
)
for additional_role in ADDITIONAL_ROLES:
cur = get_model_role(additional_role)
ct = ContentType.objects.get_for_model(additional_role).pk
roles[cur.name] = {
"id": f"ct{ct}",
"name": cur.name,
"slug": cur.slug,
"image": find_image_url(cur.slug),
}
role_images = RoleImage.objects.all()
for role_image in role_images:
roles[role_image.role.name]["image"] = role_image.get_image_url()
return render(
request,
"netbox_topology_views/images.html",
{
"roles": sorted(list(roles.values()), key=lambda r: r["name"]),
"images": images,
},
) )
+3
View File
@@ -0,0 +1,3 @@
djangorestframework
django-filter
black
+17 -17
View File
@@ -1,29 +1,29 @@
from setuptools import setup, find_packages from pathlib import Path
from os import path from setuptools import find_packages, setup
top_level_directory = path.abspath(path.dirname(__file__))
with open(path.join(top_level_directory, 'README.md'), encoding='utf-8') as file: readme = Path(__file__).parent / "README.md"
long_description = file.read() long_description = readme.read_text()
setup( setup(
name='netbox-topology-views', name="netbox-topology-views",
version='3.0.1', version="3.0.1",
description='An NetBox plugin to create Topology maps', description="An NetBox plugin to create Topology maps",
long_description=long_description, long_description=long_description,
long_description_content_type='text/markdown', long_description_content_type="text/markdown",
url='https://github.com/mattieserver/netbox-topology-views', url="https://github.com/mattieserver/netbox-topology-views",
author='Mattijs Vanhaverbeke', author="Mattijs Vanhaverbeke",
license='Apache 2.0', license="Apache 2.0",
install_requires=[], install_requires=[],
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
keywords=['netbox-plugin'], keywords=["netbox-plugin"],
classifiers=[ classifiers=[
'Programming Language :: Python', "Programming Language :: Python",
'Programming Language :: Python :: 3', "Programming Language :: Python :: 3",
'Programming Language :: Python :: 3 :: Only', "Programming Language :: Python :: 3 :: Only",
], ],
project_urls={ project_urls={
'Source': 'https://github.com/mattieserver/netbox-topology-views', "Source": "https://github.com/mattieserver/netbox-topology-views",
}, },
) )