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:
@@ -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`
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
router.register("save-coords", views.SaveCoordsViewSet)
|
||||||
|
router.register("images", views.SaveRoleImageViewSet)
|
||||||
|
|
||||||
urlpatterns = router.urls
|
urlpatterns = router.urls
|
||||||
@@ -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: str = request.data.get("node_id", None)
|
||||||
device_id = device_id.lstrip('c')
|
x_coord = request.data.get("x", None)
|
||||||
actual_device= Circuit.objects.get(id=device_id)
|
y_coord = request.data.get("y", None)
|
||||||
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:
|
actual_device = None
|
||||||
actual_device.custom_field_data["coordinates"] = "%s;%s" % (x_coord,y_coord)
|
if device_id.startswith("c"):
|
||||||
actual_device.save()
|
device_id = device_id.lstrip("c")
|
||||||
results["status"] = "saved coords"
|
actual_device = Circuit.objects.get(id=device_id)
|
||||||
else:
|
elif device_id.startswith("p"):
|
||||||
try:
|
device_id = device_id.lstrip("p")
|
||||||
actual_device.custom_field_data["coordinates"] = "%s;%s" % (x_coord,y_coord)
|
actual_device = PowerPanel.objects.get(id=device_id)
|
||||||
actual_device.save()
|
elif device_id.startswith("f"):
|
||||||
results["status"] = "saved coords"
|
device_id = device_id.lstrip("f")
|
||||||
except :
|
actual_device = PowerFeed.objects.get(id=device_id)
|
||||||
results["status"] = "coords custom field not created"
|
elif device_id.isnumeric():
|
||||||
return Response(status=500)
|
actual_device = Device.objects.get(id=device_id)
|
||||||
|
|
||||||
return Response(results)
|
if not actual_device:
|
||||||
else:
|
return Response({"status": "invalid node_id in body"}, status=400)
|
||||||
results["status"] = "not allowed to save coords"
|
|
||||||
return Response(results, status=500)
|
|
||||||
|
|
||||||
|
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"})
|
||||||
|
|||||||
@@ -1,48 +1,42 @@
|
|||||||
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,
|
||||||
@@ -51,13 +45,11 @@ class DeviceFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
|||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}")
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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__),
|
||||||
|
)
|
||||||
+478
-179
@@ -1,93 +1,146 @@
|
|||||||
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
|
||||||
@@ -98,215 +151,369 @@ def create_node(device, save_coords, circuit = None, powerpanel = None, powerfee
|
|||||||
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 = CableTermination.objects.filter( Q(_device_id__in=device_ids) ).select_related("termination_type")
|
links: QuerySet[CableTermination] = CableTermination.objects.filter(
|
||||||
wlan_links = WirelessLink.objects.filter( Q(_interface_a_device_id__in=device_ids) & Q(_interface_b_device_id__in=device_ids))
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
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:
|
||||||
|
nodes_devices[
|
||||||
|
wlan_link.interface_b.device.pk
|
||||||
|
] = 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_a = {
|
||||||
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}
|
"termination_name": wlan_link.interface_a.name,
|
||||||
wireless = {"ssid": wlan_link.ssid }
|
"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 = {}
|
||||||
|
|
||||||
@@ -317,63 +524,155 @@ def get_topology_data(queryset, hide_unconnected, save_coords, show_circuit, sho
|
|||||||
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,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
djangorestframework
|
||||||
|
django-filter
|
||||||
|
black
|
||||||
@@ -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",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user