Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f187e75fff | ||
|
|
c6df846bde | ||
|
|
52c2611e9d | ||
|
|
5c87694b87 | ||
|
|
94f5314649 | ||
|
|
32042ff2cc | ||
|
|
9ce13cd1d2 | ||
|
|
b4e2f2418e | ||
|
|
5563e3c084 | ||
|
|
77a8a2c0d6 | ||
|
|
010179742c | ||
|
|
f2bbaed317 | ||
|
|
8c56f21f16 | ||
|
|
eb627bdedc | ||
|
|
291c033e34 | ||
|
|
1f389b3faa | ||
|
|
3d3ddaa7c4 | ||
|
|
4ea2097429 | ||
|
|
cfaba462d5 | ||
|
|
5e2f1cca57 |
@@ -29,26 +29,38 @@ Once installed, the plugin needs to be enabled in your `configuration.py`
|
||||
PLUGINS = ["netbox_topology_views"]
|
||||
```
|
||||
|
||||
Then run `python3 manage.py collectstatic --no-input`
|
||||
First run `source /opt/netbox/venv/bin/activate` to enter the Python virtual environment.
|
||||
|
||||
|
||||
Then run
|
||||
```bash
|
||||
cd /opt/netbox/netbox
|
||||
pip3 install netbox-topology-views
|
||||
python3 manage.py migrate netbox_topology_views
|
||||
python3 manage.py collectstatic --no-input
|
||||
```
|
||||
|
||||
### Versions
|
||||
|
||||
| netbox version | netbox-topology-views version |
|
||||
| ------------- |-------------|
|
||||
| >= 3.3.0 | >= v3.0.0 |
|
||||
| >= 3.2.0 | >= v1.1.0 |
|
||||
| >= 3.1.8 | >= v1.0.0 |
|
||||
| >= 2.11.1 | >= v0.5.3 |
|
||||
| >= 2.10.0 | >= v0.5.0 |
|
||||
| < 2.10.0 | =< v0.4.10 |
|
||||
| netbox version | netbox-topology-views version |
|
||||
| -------------- | ----------------------------- |
|
||||
| >= 3.3.0 | >= v3.0.0 |
|
||||
| >= 3.2.0 | >= v1.1.0 |
|
||||
| >= 3.1.8 | >= v1.0.0 |
|
||||
| >= 2.11.1 | >= v0.5.3 |
|
||||
| >= 2.10.0 | >= v0.5.0 |
|
||||
| < 2.10.0 | =< v0.4.10 |
|
||||
|
||||
### Custom field: coordinates
|
||||
|
||||
There is also support for custom fields.
|
||||
|
||||
If you create a custom field "coordinates" for "dcim > device" and "Circuits > circuit" with type "text" and name "coordinates" you will see the same layout every time.
|
||||
If you create a custom field "coordinates" for "dcim > device" and "Circuits > circuit" with type "text" and name "coordinates" you will see the same layout every time. It is recommended to set this field to "UI visibility" "Hidden" and let the plugin manage it in the background.
|
||||
|
||||
The coordinates can then be provided as: "X;Y"
|
||||
The coordinates are stored as: "X;Y".
|
||||
|
||||
Please read the "Configure" chapter to set the `allow_coordinates_saving` option to True.
|
||||
You might also set the `always_save_coordinates` option to True.
|
||||
|
||||
## Configure
|
||||
|
||||
@@ -58,27 +70,25 @@ Example:
|
||||
```
|
||||
PLUGINS_CONFIG = {
|
||||
'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 |
|
||||
| ------------- |-------------| -----|
|
||||
| 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. |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| Setting | Default value | Description |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| ignore_cable_type | [] | The cable types that you want to ignore in the views |
|
||||
| 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) |
|
||||
|
||||
### Custom Images
|
||||
|
||||
You upload you own custom images to the netbox static dir (`static/netbox_topology_views/img/`).
|
||||
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.
|
||||
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.
|
||||
|
||||
## Use
|
||||
|
||||
@@ -88,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 `python3 manage.py migrate netbox_topology_views`
|
||||
|
||||
Run `python3 manage.py collectstatic --no-input`
|
||||
|
||||
|
||||
@@ -101,3 +113,12 @@ To view `/plugins/topology-views/` you need the following permissions:
|
||||
+ dcim | site | can view site
|
||||
+ extras | tag | can view tag
|
||||
+ dcim | device role | can view device role
|
||||
|
||||
## Icons info
|
||||
|
||||
Power icons created by [Freepik - Flaticon](https://www.flaticon.com/free-icons/power).
|
||||
|
||||
Power icons created by [Flat Icons - Flaticon](https://www.flaticon.com/free-icons/power)
|
||||
|
||||
Provider icons created by [Good Ware - Flaticon](https://www.flaticon.com/free-icons/provider)
|
||||
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
from extras.plugins import PluginConfig
|
||||
|
||||
|
||||
class TopologyViewsConfig(PluginConfig):
|
||||
name = 'netbox_topology_views'
|
||||
verbose_name = 'Topology views'
|
||||
description = 'An plugin to render topology maps'
|
||||
version = '3.0.0-beta.1'
|
||||
author = 'Mattijs Vanhaverbeke'
|
||||
author_email = 'author@example.com'
|
||||
base_url = 'netbox_topology_views'
|
||||
name = "netbox_topology_views"
|
||||
verbose_name = "Topology views"
|
||||
description = "An plugin to render topology maps"
|
||||
version = "3.1.0"
|
||||
author = "Mattijs Vanhaverbeke"
|
||||
author_email = "author@example.com"
|
||||
base_url = "netbox_topology_views"
|
||||
required_settings = []
|
||||
default_settings = {
|
||||
'preselected_device_roles': ['Firewall', 'Router', 'Distribution Switch', 'Core Switch', 'Internal Switch', 'Access Switch', 'Server', 'Storage', 'Backup', 'Wireless AP'],
|
||||
'ignore_cable_type': [],
|
||||
'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'],
|
||||
'allow_coordinates_saving': False,
|
||||
'always_save_coordinates': False,
|
||||
'preselected_tags' : [],
|
||||
'draw_default_layout': False
|
||||
"preselected_device_roles": [
|
||||
"Firewall",
|
||||
"Router",
|
||||
"Distribution Switch",
|
||||
"Core Switch",
|
||||
"Internal Switch",
|
||||
"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
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""REST API"""
|
||||
"""REST API"""
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
from dcim.models import Device, DeviceRole
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
|
||||
from dcim.models import DeviceRole, Device
|
||||
from extras.models import Tag
|
||||
from netbox_topology_views.models import RoleImage
|
||||
|
||||
|
||||
class TopologyDummySerializer(ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
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 . import views
|
||||
from netbox.api.routers import NetBoxRouter
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
from netbox_topology_views.api import views
|
||||
|
||||
router.register('save-coords', views.SaveCoordsViewSet, basename='save_coords')
|
||||
router = NetBoxRouter()
|
||||
|
||||
urlpatterns = router.urls
|
||||
router.register("save-coords", views.SaveCoordsViewSet)
|
||||
router.register("images", views.SaveRoleImageViewSet)
|
||||
|
||||
urlpatterns = router.urls
|
||||
|
||||
@@ -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.response import Response
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from rest_framework.routers import APIRootView
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
|
||||
|
||||
from .serializers import TopologyDummySerializer
|
||||
from django.conf import settings
|
||||
|
||||
from dcim.models import DeviceRole, Device, Cable , PowerPanel, PowerFeed
|
||||
from circuits.models import Circuit
|
||||
from extras.models import Tag
|
||||
|
||||
|
||||
class TopologyViewsRootView(APIRootView):
|
||||
def get_view_name(self):
|
||||
return 'TopologyViews'
|
||||
from netbox_topology_views.api.serializers import (
|
||||
RoleImageSerializer,
|
||||
TopologyDummySerializer,
|
||||
)
|
||||
from netbox_topology_views.models import RoleImage
|
||||
from netbox_topology_views.utils import get_image_from_url
|
||||
|
||||
|
||||
class SaveCoordsViewSet(ReadOnlyModelViewSet):
|
||||
queryset = Device.objects.all()
|
||||
queryset = Device.objects.none()
|
||||
serializer_class = TopologyDummySerializer
|
||||
|
||||
@action(detail=False, methods=['patch'])
|
||||
@action(detail=False, methods=["patch"])
|
||||
def save_coords(self, request):
|
||||
results = {}
|
||||
if settings.PLUGINS_CONFIG["netbox_topology_views"]["allow_coordinates_saving"]:
|
||||
device_id = None
|
||||
x_coord = None
|
||||
y_coord = None
|
||||
if "node_id" in request.data:
|
||||
if request.data["node_id"]:
|
||||
device_id = request.data["node_id"]
|
||||
if "x" in request.data:
|
||||
if request.data["x"]:
|
||||
x_coord = request.data["x"]
|
||||
if "y" in request.data:
|
||||
if request.data["y"]:
|
||||
y_coord = request.data["y"]
|
||||
|
||||
if device_id.startswith("c"):
|
||||
device_id = device_id.lstrip('c')
|
||||
actual_device= Circuit.objects.get(id=device_id)
|
||||
elif device_id.startswith("p"):
|
||||
device_id = device_id.lstrip('p')
|
||||
actual_device= PowerPanel.objects.get(id=device_id)
|
||||
elif device_id.startswith("f"):
|
||||
device_id = device_id.lstrip('f')
|
||||
actual_device= PowerFeed.objects.get(id=device_id)
|
||||
else:
|
||||
actual_device= Device.objects.get(id=device_id)
|
||||
if not settings.PLUGINS_CONFIG["netbox_topology_views"][
|
||||
"allow_coordinates_saving"
|
||||
]:
|
||||
return Response({"status": "not allowed to save coords"}, status=500)
|
||||
|
||||
if "coordinates" in actual_device.custom_field_data:
|
||||
actual_device.custom_field_data["coordinates"] = "%s;%s" % (x_coord,y_coord)
|
||||
actual_device.save()
|
||||
results["status"] = "saved coords"
|
||||
else:
|
||||
try:
|
||||
actual_device.custom_field_data["coordinates"] = "%s;%s" % (x_coord,y_coord)
|
||||
actual_device.save()
|
||||
results["status"] = "saved coords"
|
||||
except :
|
||||
results["status"] = "coords custom field not created"
|
||||
return Response(status=500)
|
||||
device_id: str = request.data.get("node_id", None)
|
||||
x_coord = request.data.get("x", None)
|
||||
y_coord = request.data.get("y", None)
|
||||
|
||||
return Response(results)
|
||||
else:
|
||||
results["status"] = "not allowed to save coords"
|
||||
return Response(results, status=500)
|
||||
actual_device = None
|
||||
if device_id.startswith("c"):
|
||||
device_id = device_id.lstrip("c")
|
||||
actual_device = Circuit.objects.get(id=device_id)
|
||||
elif device_id.startswith("p"):
|
||||
device_id = device_id.lstrip("p")
|
||||
actual_device = PowerPanel.objects.get(id=device_id)
|
||||
elif device_id.startswith("f"):
|
||||
device_id = device_id.lstrip("f")
|
||||
actual_device = PowerFeed.objects.get(id=device_id)
|
||||
elif device_id.isnumeric():
|
||||
actual_device = Device.objects.get(id=device_id)
|
||||
|
||||
if not actual_device:
|
||||
return Response({"status": "invalid node_id in body"}, status=400)
|
||||
|
||||
try:
|
||||
actual_device.custom_field_data["coordinates"] = "%s;%s" % (
|
||||
x_coord,
|
||||
y_coord,
|
||||
)
|
||||
actual_device.save()
|
||||
except:
|
||||
return Response(
|
||||
{"status": "coords custom field could not be saved"}, status=500
|
||||
)
|
||||
|
||||
return Response({"status": "saved coords"})
|
||||
|
||||
|
||||
class SaveRoleImageViewSet(PermissionRequiredMixin, ViewSet):
|
||||
queryset = DeviceRole.objects.none()
|
||||
serializer_class = RoleImageSerializer
|
||||
permission_required = (
|
||||
"dcim.add_device_role",
|
||||
"dcim.change_device_role",
|
||||
)
|
||||
|
||||
def create(self, request):
|
||||
if not isinstance(request.data, dict):
|
||||
return JsonResponse(
|
||||
{"status": "Missing or malformed request body"}, status=400
|
||||
)
|
||||
|
||||
device_roles = {k: v for k, v in request.data.items() if k.isnumeric()}
|
||||
content_type_ids = {
|
||||
k[2:]: v
|
||||
for k, v in request.data.items()
|
||||
if k.startswith("ct") and k[2:].isnumeric()
|
||||
}
|
||||
|
||||
roles: Dict[int, DeviceRole] = DeviceRole.objects.in_bulk(device_roles.keys())
|
||||
content_types: Dict[int, ContentType] = ContentType.objects.in_bulk(
|
||||
content_type_ids.keys()
|
||||
)
|
||||
|
||||
if len(roles) != len(device_roles):
|
||||
difference = set(device_roles) - set(roles.keys())
|
||||
return JsonResponse(
|
||||
{"status": f"Got unknown device role ids: {difference}"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if len(content_types) != len(content_type_ids):
|
||||
difference = set(content_type_ids) - set(content_types.keys())
|
||||
return JsonResponse(
|
||||
{"status": f"Got unknown content type ids: {difference}"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if device_roles:
|
||||
device_role_ct = ContentType.objects.get_for_model(DeviceRole)
|
||||
|
||||
for id, url in device_roles.items():
|
||||
RoleImage.objects.update_or_create(
|
||||
{
|
||||
"content_type_id": device_role_ct.pk,
|
||||
"object_id": id,
|
||||
"image": str(get_image_from_url(url)),
|
||||
},
|
||||
object_id=id,
|
||||
)
|
||||
|
||||
for content_type_id, url in content_type_ids.items():
|
||||
RoleImage.objects.update_or_create(
|
||||
{
|
||||
"content_type_id": content_type_id,
|
||||
"image": str(get_image_from_url(url)),
|
||||
},
|
||||
content_type_id=content_type_id,
|
||||
)
|
||||
|
||||
return JsonResponse({"status": "Ok"})
|
||||
|
||||
@@ -1,59 +1,55 @@
|
||||
from cProfile import label
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.models import Device, DeviceRole, Region, Site, Location
|
||||
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 tenancy.models import TenantGroup, Tenant
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
|
||||
from utilities.filters import TreeNodeMultipleChoiceFilter
|
||||
|
||||
|
||||
class DeviceFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
method="search",
|
||||
label="Search",
|
||||
)
|
||||
device_role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device_role_id',
|
||||
field_name="device_role_id",
|
||||
queryset=DeviceRole.objects.all(),
|
||||
label='Role (ID)',
|
||||
label="Role (ID)",
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region',
|
||||
lookup_expr='in',
|
||||
label='Region (ID)',
|
||||
field_name="site__region",
|
||||
lookup_expr="in",
|
||||
label="Region (ID)",
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
label="Site (ID)",
|
||||
)
|
||||
location_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Location.objects.all(),
|
||||
field_name='location',
|
||||
lookup_expr='in',
|
||||
label='Location (ID)',
|
||||
field_name="location",
|
||||
lookup_expr="in",
|
||||
label="Location (ID)",
|
||||
)
|
||||
rack_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Rack.objects.all(),
|
||||
field_name="rack_id",
|
||||
label="Rack (ID)",
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=DeviceStatusChoices,
|
||||
null_value=None
|
||||
null_value=None,
|
||||
)
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['id', 'name']
|
||||
fields = ["id", "name"]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
"""Perform the filtered search."""
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = (
|
||||
Q(name__icontains=value)
|
||||
)
|
||||
qs_filter = Q(name__icontains=value)
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.conf import settings
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.models import Device, Site, Region, DeviceRole, Location
|
||||
from dcim.models import Device, Site, Region, DeviceRole, Location, Rack
|
||||
|
||||
from django import forms
|
||||
from dcim.choices import DeviceStatusChoices
|
||||
@@ -13,71 +13,108 @@ from tenancy.models import TenantGroup, Tenant
|
||||
from tenancy.forms import TenancyFilterForm
|
||||
from django.conf import settings
|
||||
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):
|
||||
model = Device
|
||||
fieldsets = (
|
||||
(None, ('q', 'hide_unconnected', 'save_coords', 'show_circuit', 'show_power' ,)),
|
||||
(None, ('tenant_group_id', 'tenant_id')),
|
||||
(None, ('region_id', 'site_id', 'location_id')),
|
||||
(None, ('device_role_id', )),
|
||||
(None, ('tag', 'status')),
|
||||
(
|
||||
None,
|
||||
(
|
||||
"q",
|
||||
"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(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region')
|
||||
queryset=Region.objects.all(), required=False, label=_("Region")
|
||||
)
|
||||
device_role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceRole.objects.all(),
|
||||
queryset=DeviceRole.objects.all(), required=False, label=_("Device Role")
|
||||
)
|
||||
id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
label=_('Device Role')
|
||||
label=_("Device"),
|
||||
query_params={
|
||||
"location_id": "$location_id",
|
||||
"region_id": "$region_id",
|
||||
"site_id": "$site_id",
|
||||
"role_id": "$device_role_id",
|
||||
},
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region_id',
|
||||
"region_id": "$region_id",
|
||||
},
|
||||
label=_('Site')
|
||||
label=_("Site"),
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region_id',
|
||||
'site_id': '$site_id',
|
||||
"region_id": "$region_id",
|
||||
"site_id": "$site_id",
|
||||
},
|
||||
label=_('Location')
|
||||
label=_("Location"),
|
||||
)
|
||||
rack_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
"region_id": "$region_id",
|
||||
"site_id": "$site_id",
|
||||
"location_id": "$location_id",
|
||||
},
|
||||
label=_("Rack"),
|
||||
)
|
||||
hide_unconnected = forms.BooleanField(
|
||||
label=_("Hide Unconnected"),
|
||||
required=False,
|
||||
initial=False
|
||||
label=_("Hide Unconnected"), required=False, initial=False
|
||||
)
|
||||
show_circuit = forms.BooleanField(
|
||||
label=_("Show Circuit Terminations"),
|
||||
required=False,
|
||||
initial=False
|
||||
label=_("Show Circuit Terminations"), required=False, initial=False
|
||||
)
|
||||
show_power = forms.BooleanField(
|
||||
label=_("Show Power panel/feed"),
|
||||
required=False,
|
||||
initial=False
|
||||
label=_("Show Power Feeds"), required=False, initial=False
|
||||
)
|
||||
save_coords = forms.BooleanField(
|
||||
label=_("Save Coordinates"),
|
||||
required=False,
|
||||
disabled=(not allow_coordinates_saving)
|
||||
disabled=(not allow_coordinates_saving),
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
choices=DeviceStatusChoices,
|
||||
required=False,
|
||||
label=_("Device Status")
|
||||
choices=DeviceStatusChoices, required=False, label=_("Device Status")
|
||||
)
|
||||
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 utilities.choices import ButtonColorChoices
|
||||
from extras.plugins import PluginMenuItem
|
||||
|
||||
menu_items = (
|
||||
PluginMenuItem(link="plugins:netbox_topology_views:home", link_text="Topology"),
|
||||
PluginMenuItem(
|
||||
link='plugins:netbox_topology_views:home',
|
||||
link_text='Topology'
|
||||
link="plugins:netbox_topology_views:images", link_text="Topology Images"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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 { sassPlugin } = require('esbuild-sass-plugin');
|
||||
const esbuild = require('esbuild')
|
||||
const { sassPlugin } = require('esbuild-sass-plugin')
|
||||
|
||||
const options = {
|
||||
bundle: true,
|
||||
minify: true,
|
||||
sourcemap: 'external',
|
||||
sourcesContent: false,
|
||||
logLevel: 'error',
|
||||
};
|
||||
|
||||
const ARGS = process.argv.slice(2);
|
||||
logLevel: 'error'
|
||||
}
|
||||
|
||||
const ARGS = process.argv.slice(2)
|
||||
const noCache = ARGS.includes('--no-cache')
|
||||
|
||||
async function bundleScripts() {
|
||||
const entryPoints = {
|
||||
'app': 'js/home.js'
|
||||
};
|
||||
try {
|
||||
const result = await esbuild.build({
|
||||
...options,
|
||||
outdir: '../static/netbox_topology_views/js/',
|
||||
entryPoints,
|
||||
target: 'es2016',
|
||||
});
|
||||
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}.js'`);
|
||||
}
|
||||
const entryPoints = {
|
||||
app: 'js/home.js',
|
||||
images: 'js/images.js'
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await esbuild.build({
|
||||
...options,
|
||||
outdir: '../static/netbox_topology_views/js/',
|
||||
entryPoints,
|
||||
target: 'es2016'
|
||||
})
|
||||
if (result.errors.length !== 0) return
|
||||
|
||||
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() {
|
||||
try {
|
||||
const entryPoints = {
|
||||
'vendor': 'css/_external.scss',
|
||||
'app': 'css/app.scss',
|
||||
};
|
||||
const pluginOptions = { outputStyle: 'compressed' };
|
||||
// Allow cache disabling.
|
||||
if (ARGS.includes('--no-cache')) {
|
||||
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'`);
|
||||
const entryPoints = {
|
||||
vendor: 'css/_external.scss',
|
||||
app: 'css/app.scss'
|
||||
}
|
||||
const pluginOptions = { outputStyle: 'compressed' }
|
||||
// Allow cache disabling.
|
||||
if (noCache) {
|
||||
pluginOptions.cache = false
|
||||
}
|
||||
}
|
||||
} 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() {
|
||||
if (ARGS.includes('--styles')) {
|
||||
// Only run style jobs.
|
||||
return await bundleStyles();
|
||||
} else if (ARGS.includes('--scripts')) {
|
||||
// Only run script jobs.
|
||||
return await bundleScripts();
|
||||
// Only run style jobs.
|
||||
return await bundleStyles()
|
||||
}
|
||||
await bundleStyles();
|
||||
await bundleScripts();
|
||||
if (ARGS.includes('--scripts')) {
|
||||
// Only run script jobs.
|
||||
return await bundleScripts()
|
||||
}
|
||||
await bundleStyles()
|
||||
await bundleScripts()
|
||||
}
|
||||
|
||||
bundleAll();
|
||||
|
||||
bundleAll()
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
#visgraph {
|
||||
height: 70vh;
|
||||
}
|
||||
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: 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,36 +1,31 @@
|
||||
import { DataSet } from "vis-data/esnext";
|
||||
import { Network } from "vis-network/esnext";
|
||||
//import 'vis-util';
|
||||
import { DataSet } from 'vis-data/esnext'
|
||||
import { Network } from 'vis-network/esnext'
|
||||
import { getCookie } from './csrftoken.js'
|
||||
|
||||
|
||||
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 = {
|
||||
const options = {
|
||||
interaction: {
|
||||
hover: true,
|
||||
hoverConnectedEdges: true,
|
||||
multiselect: false
|
||||
multiselect: true
|
||||
},
|
||||
nodes: {
|
||||
shape: 'image',
|
||||
brokenImage: '../../static/netbox_topology_views/img/role-unknown.png',
|
||||
brokenImage: brokenImage ?? '',
|
||||
size: 35,
|
||||
font: {
|
||||
multi: 'md',
|
||||
face: 'helvetica',
|
||||
},
|
||||
color:
|
||||
document.documentElement.dataset.netboxColorMode === 'dark'
|
||||
? '#fff'
|
||||
: '#000'
|
||||
}
|
||||
},
|
||||
edges: {
|
||||
length: 100,
|
||||
width: 2,
|
||||
font: {
|
||||
face: 'helvetica',
|
||||
face: 'helvetica'
|
||||
},
|
||||
shadow: {
|
||||
enabled: true
|
||||
@@ -39,149 +34,114 @@ var options = {
|
||||
physics: {
|
||||
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() {
|
||||
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');
|
||||
};
|
||||
// Load CSRF token
|
||||
const csrftoken = getCookie('csrftoken')
|
||||
|
||||
export function performGraphDownload() {
|
||||
var tempDownloadLink = document.createElement('a');
|
||||
var generatedImageUrl = canvas.toDataURL(MIME_TYPE);
|
||||
// Render vis graph
|
||||
let graph = null // vis graph instance
|
||||
|
||||
tempDownloadLink.href = generatedImageUrl;
|
||||
tempDownloadLink.download = "topology";
|
||||
document.body.appendChild(tempDownloadLink);
|
||||
tempDownloadLink.click();
|
||||
document.body.removeChild(tempDownloadLink);
|
||||
};
|
||||
const container = document.querySelector('#visgraph')
|
||||
const coordSaveCheckbox = document.querySelector('#id_save_coords')
|
||||
;(function handleLoadData() {
|
||||
if (!topologyData) return
|
||||
|
||||
export function handleLoadData() {
|
||||
if (topology_data !== null) {
|
||||
|
||||
if (htmlElement.dataset.netboxColorMode == "dark") {
|
||||
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");
|
||||
}
|
||||
|
||||
});
|
||||
function htmlTitle(text) {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = text
|
||||
return container
|
||||
}
|
||||
};
|
||||
|
||||
export function load_doc() {
|
||||
if (document.readyState !== 'loading') {
|
||||
iniPlotboxIndex();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', iniPlotboxIndex);
|
||||
}
|
||||
};
|
||||
const nodes = new DataSet(
|
||||
topologyData.nodes.map((node) => ({
|
||||
...node,
|
||||
title: htmlTitle(node.title)
|
||||
}))
|
||||
)
|
||||
|
||||
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/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()
|
||||
}
|
||||
}
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
{
|
||||
"name": "netbox_topology_views",
|
||||
"version": "3.1.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
"@egjs/hammerjs": {
|
||||
"version": "2.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
||||
"integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/hammerjs": "^2.0.36"
|
||||
}
|
||||
},
|
||||
"@esbuild/linux-loong64": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz",
|
||||
"integrity": "sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@types/hammerjs": {
|
||||
"version": "2.0.41",
|
||||
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.41.tgz",
|
||||
"integrity": "sha512-ewXv/ceBaJprikMcxCmWU1FKyMAQ2X7a9Gtmzw8fcg2kIePI1crERDM818W+XYrxqdBBOdlf2rm137bU+BltCA==",
|
||||
"dev": true
|
||||
},
|
||||
"anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
}
|
||||
},
|
||||
"binary-extensions": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
|
||||
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
|
||||
"dev": true
|
||||
},
|
||||
"braces": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fill-range": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"chokidar": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
||||
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"fsevents": "~2.3.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
}
|
||||
},
|
||||
"component-emitter": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
|
||||
"integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
|
||||
"dev": true
|
||||
},
|
||||
"esbuild": {
|
||||
"version": "0.12.29",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.12.29.tgz",
|
||||
"integrity": "sha512-w/XuoBCSwepyiZtIRsKsetiLDUVGPVw1E/R3VTFSecIy8UR7Cq3SOtwKHJMFoVqqVG36aGkzh4e8BvpO1Fdc7g==",
|
||||
"dev": true
|
||||
},
|
||||
"esbuild-android-64": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz",
|
||||
"integrity": "sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-android-arm64": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz",
|
||||
"integrity": "sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-darwin-64": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz",
|
||||
"integrity": "sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-darwin-arm64": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz",
|
||||
"integrity": "sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-freebsd-64": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz",
|
||||
"integrity": "sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-freebsd-arm64": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz",
|
||||
"integrity": "sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-32": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz",
|
||||
"integrity": "sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-64": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz",
|
||||
"integrity": "sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-arm": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz",
|
||||
"integrity": "sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-arm64": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz",
|
||||
"integrity": "sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-mips64le": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz",
|
||||
"integrity": "sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-ppc64le": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz",
|
||||
"integrity": "sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-riscv64": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz",
|
||||
"integrity": "sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-s390x": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz",
|
||||
"integrity": "sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-netbsd-64": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz",
|
||||
"integrity": "sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-openbsd-64": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz",
|
||||
"integrity": "sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-sass-plugin": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-sass-plugin/-/esbuild-sass-plugin-1.8.2.tgz",
|
||||
"integrity": "sha512-ZBjONsRSpmzMKvxSNohnNXCNaBBVlYLqYhyZtN8leGGkJktCvVMKC6g9V5TWIemk1SEaW2XK+YFxz3Whw3+YYw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"esbuild": "^0.14.5",
|
||||
"picomatch": "^2.3.0",
|
||||
"resolve": "^1.20.0",
|
||||
"sass": "^1.45.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"esbuild": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.54.tgz",
|
||||
"integrity": "sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@esbuild/linux-loong64": "0.14.54",
|
||||
"esbuild-android-64": "0.14.54",
|
||||
"esbuild-android-arm64": "0.14.54",
|
||||
"esbuild-darwin-64": "0.14.54",
|
||||
"esbuild-darwin-arm64": "0.14.54",
|
||||
"esbuild-freebsd-64": "0.14.54",
|
||||
"esbuild-freebsd-arm64": "0.14.54",
|
||||
"esbuild-linux-32": "0.14.54",
|
||||
"esbuild-linux-64": "0.14.54",
|
||||
"esbuild-linux-arm": "0.14.54",
|
||||
"esbuild-linux-arm64": "0.14.54",
|
||||
"esbuild-linux-mips64le": "0.14.54",
|
||||
"esbuild-linux-ppc64le": "0.14.54",
|
||||
"esbuild-linux-riscv64": "0.14.54",
|
||||
"esbuild-linux-s390x": "0.14.54",
|
||||
"esbuild-netbsd-64": "0.14.54",
|
||||
"esbuild-openbsd-64": "0.14.54",
|
||||
"esbuild-sunos-64": "0.14.54",
|
||||
"esbuild-windows-32": "0.14.54",
|
||||
"esbuild-windows-64": "0.14.54",
|
||||
"esbuild-windows-arm64": "0.14.54"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"esbuild-sunos-64": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz",
|
||||
"integrity": "sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-windows-32": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz",
|
||||
"integrity": "sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-windows-64": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz",
|
||||
"integrity": "sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-windows-arm64": {
|
||||
"version": "0.14.54",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz",
|
||||
"integrity": "sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"fill-range": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
||||
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"function-bind": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
|
||||
"dev": true
|
||||
},
|
||||
"glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-glob": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"has": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
|
||||
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"function-bind": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"immutable": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz",
|
||||
"integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==",
|
||||
"dev": true
|
||||
},
|
||||
"is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"is-core-module": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",
|
||||
"integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"has": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true
|
||||
},
|
||||
"is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-extglob": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true
|
||||
},
|
||||
"keycharm": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/keycharm/-/keycharm-0.4.0.tgz",
|
||||
"integrity": "sha512-TyQTtsabOVv3MeOpR92sIKk/br9wxS+zGj4BG7CR8YbK4jM3tyIBaF0zhzeBUMx36/Q/iQLOKKOT+3jOQtemRQ==",
|
||||
"dev": true
|
||||
},
|
||||
"normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true
|
||||
},
|
||||
"path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true
|
||||
},
|
||||
"picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true
|
||||
},
|
||||
"readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"picomatch": "^2.2.1"
|
||||
}
|
||||
},
|
||||
"resolve": {
|
||||
"version": "1.22.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
|
||||
"integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-core-module": "^2.9.0",
|
||||
"path-parse": "^1.0.7",
|
||||
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"sass": {
|
||||
"version": "1.56.2",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.56.2.tgz",
|
||||
"integrity": "sha512-ciEJhnyCRwzlBCB+h5cCPM6ie/6f8HrhZMQOf5vlU60Y1bI1rx5Zb0vlDZvaycHsg/MqFfF1Eq2eokAa32iw8w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chokidar": ">=3.0.0 <4.0.0",
|
||||
"immutable": "^4.0.0",
|
||||
"source-map-js": ">=0.6.2 <2.0.0"
|
||||
}
|
||||
},
|
||||
"source-map-js": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
|
||||
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
|
||||
"dev": true
|
||||
},
|
||||
"supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true
|
||||
},
|
||||
"timsort": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz",
|
||||
"integrity": "sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A==",
|
||||
"dev": true
|
||||
},
|
||||
"to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-number": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"dev": true
|
||||
},
|
||||
"vis-data": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-7.1.4.tgz",
|
||||
"integrity": "sha512-usy+ePX1XnArNvJ5BavQod7YRuGQE1pjFl+pu7IS6rCom2EBoG0o1ZzCqf3l5US6MW51kYkLR+efxRbnjxNl7w=="
|
||||
},
|
||||
"vis-network": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vis-network/-/vis-network-9.1.2.tgz",
|
||||
"integrity": "sha512-BdapguKg7sk3NvdZaDsM7T6rNhOBFz0/F4ZScxctK4klRzQPLQPTEcmbioXaZhMkkgWymzBR3lFCxL1q+eYyAw=="
|
||||
},
|
||||
"vis-util": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vis-util/-/vis-util-5.0.3.tgz",
|
||||
"integrity": "sha512-Wf9STUcFrDzK4/Zr7B6epW2Kvm3ORNWF+WiwEz2dpf5RdWkLUXFSbLcuB88n1W6tCdFwVN+v3V4/Xmn9PeL39g=="
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "netbox_topology_views",
|
||||
"version": "3.0.0-beta.1",
|
||||
"version": "3.1.0",
|
||||
"scripts": {
|
||||
"bundle": "node bundle.js",
|
||||
"bundle:styles": "node bundle.js --styles",
|
||||
@@ -15,7 +15,6 @@
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.12.24",
|
||||
"esbuild-sass-plugin": "^1.5.2",
|
||||
|
||||
"@egjs/hammerjs": "^2.0.0",
|
||||
"component-emitter": "^1.3.0",
|
||||
"keycharm": "^0.4.0",
|
||||
|
||||
@@ -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="panel-body">
|
||||
<div id="visgraph" class=""></div>
|
||||
<div id="visgraph"></div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var topology_data = {{ topology_data | safe }};
|
||||
</script>
|
||||
</div>
|
||||
|
||||
{% if filter_form %}
|
||||
@@ -69,5 +65,9 @@
|
||||
{% endblock content-wrapper %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
@@ -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.views.generic.base import RedirectView
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
# 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.
|
||||
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__),
|
||||
)
|
||||
+499
-199
@@ -1,378 +1,678 @@
|
||||
from platform import node
|
||||
from tkinter.messagebox import NO
|
||||
from django.shortcuts import get_object_or_404, 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
|
||||
from functools import reduce
|
||||
from typing import DefaultDict, Dict, Optional, Union
|
||||
|
||||
from dcim.models import Device, Cable, CableTermination, DeviceRole, PathEndpoint, Interface, FrontPort, RearPort, PowerPanel, PowerFeed
|
||||
from circuits.models import CircuitTermination, ProviderNetwork
|
||||
from wireless.models import WirelessLink
|
||||
from circuits.models import Circuit, CircuitTermination
|
||||
from dcim.models import (
|
||||
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 wireless.models import WirelessLink
|
||||
|
||||
supported_termination_types = ["interface", "front port", "rear port", "power outlet", "power 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_content = ""
|
||||
if circuit:
|
||||
dev_name = "Circuit " + str(device.cid)
|
||||
node["image"] = "../../static/netbox_topology_views/img/circuit.png"
|
||||
node["id"] = "c{}".format(device.id)
|
||||
if isinstance(device, Circuit):
|
||||
dev_name = f"Circuit {device.cid}"
|
||||
node["id"] = f"c{device.pk}"
|
||||
|
||||
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:
|
||||
node_content += "<tr><th>Type: </th><td>" + device.type.name + "</td></tr>"
|
||||
elif powerpanel:
|
||||
dev_name = "Power Panel " + str(device.id)
|
||||
node["image"] = "../../static/netbox_topology_views/img/power-panel.png"
|
||||
node["id"] = "p{}".format(device.id)
|
||||
node_content += f"<tr><th>Type: </th><td>{device.type.name}</td></tr>"
|
||||
elif isinstance(device, PowerPanel):
|
||||
dev_name = f"Power Panel {device.pk}"
|
||||
node["id"] = f"p{device.pk}"
|
||||
|
||||
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:
|
||||
node_content += "<tr><th>Location: </th><td>" + device.location.name + "</td></tr>"
|
||||
elif powerfeed:
|
||||
dev_name = "Power Feed " + str(device.id)
|
||||
node["image"] = "../../static/netbox_topology_views/img/power-feed.png"
|
||||
node["id"] = "f{}".format(device.id)
|
||||
node_content += (
|
||||
f"<tr><th>Location: </th><td>{device.location.name}</td></tr>"
|
||||
)
|
||||
elif isinstance(device, PowerFeed):
|
||||
dev_name = f"Power Feed {device.pk}"
|
||||
node["id"] = f"f{device.pk}"
|
||||
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
dev_name = device.name
|
||||
if dev_name is None:
|
||||
dev_name = "device name unknown"
|
||||
|
||||
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:
|
||||
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 != "":
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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.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:
|
||||
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
|
||||
|
||||
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"
|
||||
node["id"] = device.pk
|
||||
|
||||
if device.device_role.color != "":
|
||||
node["color.border"] = "#" + device.device_role.color
|
||||
|
||||
|
||||
dev_title = "<table><tbody> %s</tbody></table>" % (node_content)
|
||||
|
||||
node["title"] = dev_title
|
||||
node["name"] = dev_name
|
||||
node["label"] = dev_name
|
||||
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 device.custom_field_data["coordinates"] is not None:
|
||||
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["y"] = int(cords[1])
|
||||
node["physics"] = False
|
||||
else:
|
||||
if save_coords:
|
||||
node["physics"] = False
|
||||
else:
|
||||
node["physics"] = True
|
||||
elif save_coords:
|
||||
node["physics"] = False
|
||||
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"]
|
||||
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"]
|
||||
|
||||
def create_edge(
|
||||
edge_id: int,
|
||||
termination_a: Dict,
|
||||
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["id"] = edge_id
|
||||
edge["from"] = termination_a["device_id"]
|
||||
edge["to"] = termination_b["device_id"]
|
||||
title = "Cable"
|
||||
|
||||
if circuit is not None:
|
||||
edge["dashes"] = True
|
||||
edge["title"] = "Circuit provider: " + circuit["provider_name"] + "<br>"
|
||||
edge["title"] += "Termination between <br>"
|
||||
edge["title"] += cable_b_dev_name + " [" + cable_b_name + "]<br>"
|
||||
edge["title"] += cable_a_dev_name + " [" + cable_a_name + "]"
|
||||
title = f"Circuit provider: {circuit['provider_name']}<br>Termination"
|
||||
|
||||
elif wireless is not None:
|
||||
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:
|
||||
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 + "]"
|
||||
else:
|
||||
edge["title"] = "Cable between <br> " + cable_a_dev_name + " [" + cable_a_name + "]<br>" + cable_b_dev_name + " [" + cable_b_name + "]"
|
||||
|
||||
edge["dashes"] = [5, 5, 3, 3]
|
||||
title = "Power Connection"
|
||||
|
||||
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 != "":
|
||||
edge["color"] = "#" + cable.color
|
||||
|
||||
|
||||
return edge
|
||||
|
||||
|
||||
def create_circuit_termination(termination):
|
||||
if isinstance(termination, CircuitTermination):
|
||||
return { "termination_name": termination.circuit.provider.name, "termination_device_name": termination.circuit.cid, "device_id": "c{}".format(termination.circuit.id) }
|
||||
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.id }
|
||||
return {
|
||||
"termination_name": termination.circuit.provider.name,
|
||||
"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
|
||||
|
||||
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 = {}
|
||||
edges = []
|
||||
nodes = []
|
||||
edge_ids = 0
|
||||
nodes_circuits = {}
|
||||
nodes_powerpanel = {}
|
||||
nodes_powerfeed = {}
|
||||
nodes_circuits: Dict[int, Circuit] = {}
|
||||
nodes_powerpanel: Dict[int, PowerPanel] = {}
|
||||
nodes_powerfeed: Dict[int, PowerFeed] = {}
|
||||
nodes_provider_networks = {}
|
||||
cable_ids = {}
|
||||
if not queryset:
|
||||
return None
|
||||
cable_ids = DefaultDict(dict)
|
||||
ignore_cable_type = settings.PLUGINS_CONFIG["netbox_topology_views"][
|
||||
"ignore_cable_type"
|
||||
]
|
||||
|
||||
ignore_cable_type = settings.PLUGINS_CONFIG["netbox_topology_views"]["ignore_cable_type"]
|
||||
|
||||
device_ids = [d.id for d in queryset]
|
||||
device_ids = [d.pk for d in queryset]
|
||||
site_ids = [d.site_id for d in queryset]
|
||||
|
||||
if show_circuit:
|
||||
site_ids = [d.site.id for d in queryset]
|
||||
circuits = CircuitTermination.objects.filter( Q(site_id__in=site_ids) | Q( provider_network__isnull=False) ).prefetch_related("provider_network", "circuit")
|
||||
for circuit in circuits:
|
||||
if not hide_unconnected and circuit.circuit.id not in nodes_circuits:
|
||||
nodes_circuits[circuit.circuit.id] = circuit.circuit
|
||||
circuit_terminations = CircuitTermination.objects.filter(
|
||||
Q(site_id__in=site_ids) | Q(provider_network__isnull=False)
|
||||
).prefetch_related("provider_network", "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_b = {}
|
||||
circuit_model = {}
|
||||
if circuit.cable is not None:
|
||||
termination_a = create_circuit_termination(circuit.cable.a_terminations[0])
|
||||
termination_b = create_circuit_termination(circuit.cable.b_terminations[0])
|
||||
elif circuit.provider_network is not None:
|
||||
if circuit.provider_network.id not in nodes_provider_networks:
|
||||
nodes_provider_networks[circuit.provider_network.id] = circuit.provider_network
|
||||
if circuit_termination.cable is not None:
|
||||
termination_a = create_circuit_termination(
|
||||
circuit_termination.cable.a_terminations[0]
|
||||
)
|
||||
termination_b = create_circuit_termination(
|
||||
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):
|
||||
circuit_model = {"provider_name": circuit.circuit.provider.name}
|
||||
circuit_model = {
|
||||
"provider_name": circuit_termination.circuit.provider.name
|
||||
}
|
||||
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
|
||||
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 termination.device.id not in nodes_devices and termination.device.id in device_ids:
|
||||
nodes_devices[termination.device.id] = termination.device
|
||||
if (
|
||||
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
|
||||
else:
|
||||
if termination.device.id in device_ids:
|
||||
circuit_has_connections = True
|
||||
|
||||
if termination.device_id in device_ids:
|
||||
circuit_has_connections = True
|
||||
|
||||
if circuit_has_connections and hide_unconnected:
|
||||
if circuit.circuit.id not in nodes_circuits:
|
||||
nodes_circuits[circuit.circuit.id] = circuit.circuit
|
||||
if circuit_termination.circuit_id not in nodes_circuits:
|
||||
nodes_circuits[
|
||||
circuit_termination.circuit.pk
|
||||
] = circuit_termination.circuit
|
||||
|
||||
|
||||
for d in nodes_circuits.values():
|
||||
nodes.append(create_node(d, save_coords, circuit=True))
|
||||
nodes.append(create_node(d, save_coords))
|
||||
|
||||
links: QuerySet[CableTermination] = CableTermination.objects.filter(
|
||||
Q(_device_id__in=device_ids)
|
||||
).select_related("termination_type")
|
||||
|
||||
wlan_links: QuerySet[WirelessLink] = WirelessLink.objects.filter(
|
||||
Q(_interface_a_device_id__in=device_ids)
|
||||
& Q(_interface_b_device_id__in=device_ids)
|
||||
)
|
||||
|
||||
links = CableTermination.objects.filter( Q(_device_id__in=device_ids) ).select_related("termination_type")
|
||||
wlan_links = WirelessLink.objects.filter( Q(_interface_a_device_id__in=device_ids) & Q(_interface_b_device_id__in=device_ids))
|
||||
|
||||
if show_power:
|
||||
power_panels = PowerPanel.objects.filter( Q (site_id__in=site_ids))
|
||||
power_panels_ids = [d.id for d in power_panels]
|
||||
power_feeds = PowerFeed.objects.filter( Q (power_panel_id__in=power_panels_ids))
|
||||
|
||||
for power_feed in power_feeds:
|
||||
if not hide_unconnected or (hide_unconnected and power_feed.cable_id is not None):
|
||||
if power_feed.power_panel.id not in nodes_powerpanel:
|
||||
nodes_powerpanel[power_feed.power_panel.id] = power_feed.power_panel
|
||||
power_panels_ids = PowerPanel.objects.filter(
|
||||
Q(site_id__in=site_ids)
|
||||
).values_list("pk", flat=True)
|
||||
power_feeds: QuerySet[PowerFeed] = PowerFeed.objects.filter(
|
||||
Q(power_panel_id__in=power_panels_ids)
|
||||
)
|
||||
|
||||
if power_feed.id not in nodes_powerfeed:
|
||||
for power_feed in power_feeds:
|
||||
if not hide_unconnected or (
|
||||
hide_unconnected and power_feed.cable_id is not None
|
||||
):
|
||||
if power_feed.power_panel_id not in nodes_powerpanel:
|
||||
nodes_powerpanel[power_feed.power_panel.pk] = power_feed.power_panel
|
||||
|
||||
power_link_name = ""
|
||||
if power_feed.pk not in nodes_powerfeed:
|
||||
if hide_unconnected:
|
||||
if power_feed.link_peers[0].device.id in device_ids:
|
||||
nodes_powerfeed[power_feed.id] = power_feed
|
||||
if power_feed.link_peers[0].device_id in device_ids:
|
||||
nodes_powerfeed[power_feed.pk] = power_feed
|
||||
power_link_name = power_feed.link_peers[0].name
|
||||
else:
|
||||
nodes_powerfeed[power_feed.id] = power_feed
|
||||
nodes_powerfeed[power_feed.pk] = power_feed
|
||||
|
||||
edge_ids += 1
|
||||
termination_a = { "termination_name": power_feed.power_panel.name, "termination_device_name": str(power_feed.power_panel.id), "device_id": "p{}".format(power_feed.power_panel.id) }
|
||||
termination_b = { "termination_name": power_feed.name, "termination_device_name": str(termination.id), "device_id": "f{}".format(power_feed.id) }
|
||||
edges.append(create_edge(edge_id=edge_ids, termination_a=termination_a, termination_b=termination_b, power=True))
|
||||
termination_a = {
|
||||
"termination_name": power_feed.power_panel.name,
|
||||
"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 not in cable_ids:
|
||||
cable_ids[power_feed.cable.id] = {}
|
||||
cable_ids[power_feed.cable.id][power_feed.cable_end] = termination_b
|
||||
|
||||
|
||||
cable_ids[power_feed.cable_id][power_feed.cable_end] = termination_b
|
||||
|
||||
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():
|
||||
nodes.append(create_node(d, save_coords, powerpanel = True))
|
||||
nodes.append(create_node(d, save_coords))
|
||||
|
||||
for link in links:
|
||||
if link.termination_type.name in ignore_cable_type :
|
||||
if link.termination_type.name in ignore_cable_type:
|
||||
continue
|
||||
|
||||
#Normal device cables
|
||||
|
||||
# Normal device cables
|
||||
if link.termination_type.name in supported_termination_types:
|
||||
complete_link = False
|
||||
if link.cable_end == "A":
|
||||
if link.cable.id not in cable_ids:
|
||||
cable_ids[link.cable.id] = {}
|
||||
if link.cable_id not in cable_ids:
|
||||
cable_ids[link.cable_id] = {}
|
||||
else:
|
||||
if cable_ids[link.cable.id]['B'] is not None:
|
||||
complete_link = True
|
||||
if "B" in cable_ids[link.cable_id]:
|
||||
if cable_ids[link.cable_id]["B"] is not None:
|
||||
complete_link = True
|
||||
elif link.cable_end == "B":
|
||||
if link.cable.id not in cable_ids:
|
||||
cable_ids[link.cable.id] = {}
|
||||
if link.cable_id not in cable_ids:
|
||||
cable_ids[link.cable_id] = {}
|
||||
else:
|
||||
if cable_ids[link.cable.id]['A'] is not None:
|
||||
complete_link = True
|
||||
if "A" in cable_ids[link.cable_id]:
|
||||
if cable_ids[link.cable_id]["A"] is not None:
|
||||
complete_link = True
|
||||
else:
|
||||
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:
|
||||
edge_ids += 1
|
||||
if isinstance(cable_ids[link.cable.id]["B"], CableTermination):
|
||||
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
|
||||
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 }
|
||||
if isinstance(cable_ids[link.cable_id]["B"], CableTermination):
|
||||
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
|
||||
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:
|
||||
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 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
|
||||
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 }
|
||||
if isinstance(cable_ids[link.cable_id]["A"], CableTermination):
|
||||
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
|
||||
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:
|
||||
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))
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
for wlan_link in wlan_links:
|
||||
if wlan_link.interface_a.device.id not in nodes_devices:
|
||||
nodes_devices[wlan_link.interface_a.device.id] = wlan_link.interface_a.device
|
||||
if wlan_link.interface_b.device.id not in nodes_devices:
|
||||
nodes_devices[wlan_link.interface_b.device.id] = wlan_link.interface_b.device
|
||||
|
||||
termination_a = {"termination_name": wlan_link.interface_a.name, "termination_device_name": wlan_link.interface_a.device.name, "device_id": wlan_link.interface_a.device.id}
|
||||
termination_b = {"termination_name": wlan_link.interface_b.name, "termination_device_name": wlan_link.interface_b.device.name, "device_id": wlan_link.interface_b.device.id}
|
||||
wireless = {"ssid": wlan_link.ssid }
|
||||
if wlan_link.interface_a.device_id not in nodes_devices:
|
||||
nodes_devices[
|
||||
wlan_link.interface_a.device.pk
|
||||
] = 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_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
|
||||
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:
|
||||
if qs_device.id not in nodes_devices and not hide_unconnected:
|
||||
nodes_devices[qs_device.id] = qs_device
|
||||
if qs_device.pk not in nodes_devices and not hide_unconnected:
|
||||
nodes_devices[qs_device.pk] = qs_device
|
||||
|
||||
results = {}
|
||||
|
||||
|
||||
for d in nodes_devices.values():
|
||||
nodes.append(create_node(d, save_coords))
|
||||
|
||||
results["nodes"] = nodes
|
||||
results["nodes"] = nodes
|
||||
results["edges"] = edges
|
||||
return results
|
||||
|
||||
|
||||
class TopologyHomeView(PermissionRequiredMixin, View):
|
||||
permission_required = ("dcim.view_site", "dcim.view_device")
|
||||
|
||||
"""
|
||||
Show the home page
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
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
|
||||
topo_data = None
|
||||
|
||||
if request.GET:
|
||||
save_coords = False
|
||||
if 'save_coords' in request.GET:
|
||||
if "save_coords" in request.GET:
|
||||
if request.GET["save_coords"] == "on":
|
||||
save_coords = True
|
||||
|
||||
|
||||
hide_unconnected = False
|
||||
if "hide_unconnected" in request.GET:
|
||||
if request.GET["hide_unconnected"] == "on" :
|
||||
if request.GET["hide_unconnected"] == "on":
|
||||
hide_unconnected = True
|
||||
|
||||
show_power = False
|
||||
if "show_power" in request.GET:
|
||||
if request.GET["show_power"] == "on" :
|
||||
if request.GET["show_power"] == "on":
|
||||
show_power = True
|
||||
|
||||
show_circuit = False
|
||||
if "show_circuit" in request.GET:
|
||||
if request.GET["show_circuit"] == "on" :
|
||||
if request.GET["show_circuit"] == "on":
|
||||
show_circuit = True
|
||||
|
||||
if "draw_init" in request.GET:
|
||||
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:
|
||||
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:
|
||||
preselected_device_roles = settings.PLUGINS_CONFIG["netbox_topology_views"]["preselected_device_roles"]
|
||||
preselected_tags = settings.PLUGINS_CONFIG["netbox_topology_views"]["preselected_tags"]
|
||||
always_save_coordinates = bool(settings.PLUGINS_CONFIG["netbox_topology_views"]["always_save_coordinates"])
|
||||
preselected_device_roles = settings.PLUGINS_CONFIG["netbox_topology_views"][
|
||||
"preselected_device_roles"
|
||||
]
|
||||
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_tags = Tag.objects.filter(name__in=preselected_tags).values_list("name", flat=True)
|
||||
q_device_role_id = DeviceRole.objects.filter(
|
||||
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.setlist("device_role_id", list(q_device_role_id))
|
||||
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:
|
||||
q["save_coords"] = "on"
|
||||
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" , {
|
||||
"filter_form": DeviceFilterForm(request.GET, label_suffix=""),
|
||||
"topology_data": json.dumps(topo_data)
|
||||
}
|
||||
return render(
|
||||
request,
|
||||
"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
|
||||
top_level_directory = path.abspath(path.dirname(__file__))
|
||||
with open(path.join(top_level_directory, 'README.md'), encoding='utf-8') as file:
|
||||
long_description = file.read()
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
readme = Path(__file__).parent / "README.md"
|
||||
long_description = readme.read_text()
|
||||
|
||||
setup(
|
||||
name='netbox-topology-views',
|
||||
version='3.0.0-beta.1',
|
||||
description='An NetBox plugin to create Topology maps',
|
||||
name="netbox-topology-views",
|
||||
version="3.1.0",
|
||||
description="An NetBox plugin to create Topology maps",
|
||||
long_description=long_description,
|
||||
long_description_content_type='text/markdown',
|
||||
url='https://github.com/mattieserver/netbox-topology-views',
|
||||
author='Mattijs Vanhaverbeke',
|
||||
license='Apache 2.0',
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/mattieserver/netbox-topology-views",
|
||||
author="Mattijs Vanhaverbeke",
|
||||
license="Apache 2.0",
|
||||
install_requires=[],
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
keywords=['netbox-plugin'],
|
||||
keywords=["netbox-plugin"],
|
||||
classifiers=[
|
||||
'Programming Language :: Python',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3 :: Only',
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
],
|
||||
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