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