Compare commits

..
76 Commits
Author SHA1 Message Date
Mario 89c56c8d1c v4.5.0 2026-01-07 11:08:58 +01:00
Go RayandMario 619e959a8e Fixes #683: Fix crash when rendering WirelessLink without label (#690)
* Fix crash when rendering WirelessLink without label

* removes the unnecessary str()

* Update views.py

---------

Co-authored-by: Mario <github@franzbonenkamp.de>
2025-12-30 22:31:14 +01:00
Mario 10f545b509 Reorganize non-device filters (#694) 2025-12-30 17:20:14 +01:00
Mario 3810e9567f Fix error for zero results (#692) 2025-12-30 15:00:01 +01:00
Mario 5981a21830 bump version (#682) 2025-10-15 17:34:20 +02:00
Mario 8100b8146f Remove modal window (#680) 2025-10-09 14:59:35 +02:00
Mario 6c047b1022 Add cable label in hover popup (#679) 2025-10-08 18:51:29 +02:00
valtersg 4afa4f721f Fixes #671 (#677)
* Use CSRF token from window.CSRF_TOKEN

* Removed unused csrftoken.js
2025-10-08 15:26:31 +02:00
Mario e7df2f6772 Update README.md (#670)
Added NetBox v4.4.x to version matrix.
2025-09-25 12:43:32 +02:00
Mario b85e548049 added empty result warning (#658) 2025-07-18 23:09:26 +02:00
Mario 097fe5c685 added some label items (#656) 2025-07-17 15:07:37 +02:00
尤理衡 (Li-Heng Yu) c83479fb1d Added check for cable integrity (#647) 2025-07-14 08:49:32 +02:00
mattieserver 79a234fcc9 v4.3.0 (#642) 2025-05-28 16:19:42 +02:00
larsen0815 db76752a82 Clearly specify file locations (#638) 2025-05-15 11:47:29 +02:00
Mario 47f263f5c2 Update version table in README.md (#637) 2025-05-15 09:21:27 +02:00
larsen0815 6eb20004f9 Improve installation instructions (#635) 2025-05-15 09:16:37 +02:00
Mario 836d9f40d7 Fix group boxes too small for multiline labels (#632) 2025-05-11 11:43:11 +02:00
Mario 607347ff18 Fix unnecessary field User id (#631) 2025-05-09 17:55:50 +02:00
Chris Russell 0f6051731a Fixes #627 - update template_content to models tuple (#628) 2025-05-05 12:59:03 +02:00
Kenneth Hardy 3c9aebfd2f Update README.md (#625)
Bumping version. Works on my machine.
2025-04-30 08:02:01 +02:00
Maurice Kok 60a5fe1eae fix for issue 593 (#626) 2025-04-30 08:01:13 +02:00
mattieserver 3fbeb97810 bump version (#619) 2025-03-19 22:26:39 +01:00
Tobias Genannt f3b822e18d Use new version identifier from Netbox (#608) 2025-03-19 22:08:21 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 555c1fac4c Bump esbuild and esbuild-sass-plugin (#610)
Bumps [esbuild](https://github.com/evanw/esbuild) and [esbuild-sass-plugin](https://github.com/glromeo/esbuild-sass-plugin). These dependencies needed to be updated together.

Updates `esbuild` from 0.12.29 to 0.25.0
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2021.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.12.29...v0.25.0)

Updates `esbuild-sass-plugin` from 1.8.2 to 3.3.1
- [Release notes](https://github.com/glromeo/esbuild-sass-plugin/releases)
- [Commits](https://github.com/glromeo/esbuild-sass-plugin/commits/v3.3.1)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-type: direct:development
- dependency-name: esbuild-sass-plugin
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-19 22:07:20 +01:00
mattieserver cf8641939a basic support for v4.2 (#592)
* basic support for v4.2

* bump versions

* update readme with versions

* upgrade version for v4.1.X
2025-01-10 15:41:44 +01:00
Mario 2893fff111 Closes #484: Show device information (#573)
* Add model and individual option

* Add forms and views

* minor changes
2024-11-22 21:27:25 +01:00
Mario 25fdcb6cf4 fix saved filter not honoring show_unconnected (#582) 2024-10-18 14:58:33 +02:00
Chris Russell 5d330bc830 Update plugin yaml for topology views v4.0.1 and netbox v4.1.4 (#581) 2024-10-17 17:11:23 +01:00
Nat Morris 03b8c9c76e ENG-3602 initial plugin manifest (#578)
* chore: trigger a central workflow when the manifest is modified.
* chore: import compatibility from the existing registry details
2024-10-14 18:07:58 +01:00
Mario 02a7118a33 Fixes #547: XML Export does not work in modal window (#572)
* Fix XML export in modal windows

* Removed useless concatenation

* Revert removed basePath in JS and added basePath to HTMX template
2024-09-27 21:44:06 +02:00
Mario 44e49495fe Add API functions for coordinates and coordinate groups (#571) 2024-09-27 20:46:58 +02:00
mattieserver f4b1e34826 fix name of devices (#567)
* fix name of devices

* upgrade version
2024-09-11 17:00:15 +02:00
mattieserver f8c6e9e193 bump version (#558) 2024-08-26 12:21:00 +02:00
mattieserver 19adcab4a9 set BaseViewSet (#556) 2024-08-26 12:01:42 +02:00
Mario d2d2106246 Closes #163: Show raster and snap icons (#550)
* proof of concept

* add grid size to forms and individual options
2024-08-20 12:02:45 +02:00
Mario 9072faf7cf Closes #485: Add Ignore Termination Types to "regular" filters (#546)
* add ignore_cable_type filter

* deleted commented command

* corrected saved filter for ignore_cable_type
2024-08-09 15:09:16 +02:00
Mario 6f43b959bd Closes #497: Group by Virtual Chassis (#542)
* add virtual chassis to js

* add virtual chassis to py and migrations
2024-07-29 12:15:04 +02:00
Mario 1048feff52 Fixes #464: Topology not showing properly when using other language than english (#541)
* Change termination type name to model

* change CHOICES to model names

* Change termination type name to model

* change CHOICES to model names
2024-07-29 10:32:30 +02:00
Mario 7ff0a9650f Fixed another mistake (#540) 2024-07-28 00:07:00 +02:00
Mario 8d5de5c49f fix saved filter params not defined (#539) 2024-07-27 22:26:18 +02:00
Mario 96afdf8d83 fix coordinate group in saved filter (#536) 2024-07-24 22:22:13 +02:00
aba0598947 Closes #436 (and #122?): Add options to disable smooth cables and physics (#488)
* allow disabling smooth cables and render them as straight lines instead

* rename 'disable_smooth_cables' to 'straight_cables'

* missed one in renaming

* fix inverted logic

* add GET param and option to DeviceFilterForm

* fix syntax error

* rename var

* add missing query field

* netbox v4 support

---------

Co-authored-by: Daniel Bremer <daniel.bremer@mpsd.mpg.de>
Co-authored-by: mvanhaverbeke <mattijs.vanhaverbeke@ebo-enterprises.com>
2024-07-08 15:37:18 +02:00
mattieserver 142de460b9 added restrict to query (#522) 2024-07-04 21:01:40 +02:00
mattieserver 2600209724 4.0 release (#513) 2024-06-24 20:10:05 +02:00
mattieserver ed8a717bc5 seconnd beta (#511) 2024-06-21 19:21:20 +02:00
mattieserver 0b7046b10d fix route name (#508) 2024-06-17 21:43:03 +02:00
mattieserver e784e717ca fix coordSaveCheckbox (#510) 2024-06-17 21:42:47 +02:00
2895294903 477 prepare for netbox 4.0 (#495)
* Change extras.plugins to netbox.plugins (#487)

* Remove ButtonColorChoices (#491)

* remove is_htmx

* fieldset update

* Additions to 477 prepare for netbox 4.0 (#504)

* Re-add overriding of default_renderer

Signed-off-by: Iain Buclaw <ibuclaw@gdcproject.org>

* Restore i18n of field names

Signed-off-by: Iain Buclaw <ibuclaw@gdcproject.org>

* Remove unnecessary FieldSets

Signed-off-by: Iain Buclaw <ibuclaw@gdcproject.org>

* Remove trailing whitespaces in forms.py

Signed-off-by: Iain Buclaw <ibuclaw@gdcproject.org>

* Fix error in resizeCanvas

Signed-off-by: Iain Buclaw <ibuclaw@gdcproject.org>

* Remove btn-sm class from buttons

Signed-off-by: Iain Buclaw <ibuclaw@gdcproject.org>

* Use htmx_partial instead of request.htmx

Signed-off-by: Iain Buclaw <ibuclaw@gdcproject.org>

---------

Signed-off-by: Iain Buclaw <ibuclaw@gdcproject.org>

* fix choises

* fix color switcher (use new attribute)

* fix htmx filter

* 4.0.0-beta.1 release

---------

Signed-off-by: Iain Buclaw <ibuclaw@gdcproject.org>
Co-authored-by: Mario <github@franzbonenkamp.de>
Co-authored-by: Iain Buclaw <ibuclaw@gdcproject.org>
2024-06-05 13:23:34 +02:00
mattieserver 8368ffa2fd prepare 3.9.1 (#480) 2024-04-19 15:55:04 +02:00
Mario 23428c8ad2 set models to private (#476) 2024-04-01 23:26:46 +02:00
Mario c00b8c2cfe Fixes #236: Saved filter doesn't reload show cables (#475)
* fix restore saved filter options

* fix restore saved filter group options

* fix htmx export to xml
2024-03-30 17:29:26 +01:00
Mario 32003e59f9 fix power feeds attributeerror (#474) 2024-03-30 11:17:04 +01:00
Mario bd32642b5c refine group frame color (#472) 2024-03-30 10:13:29 +01:00
Mario f463804087 added group options to api (#466) 2024-03-08 16:19:55 +01:00
Mario b92b012309 Closes #329: Draw rectangles around Sites/Locations/Racks (#462) 2024-03-03 17:52:44 +01:00
mattieserver 74c295837c bump version (#449) 2024-01-13 21:59:37 +01:00
Mario a543f5cb0c fix pixel waste (#440) 2024-01-04 21:42:19 +01:00
mattieserver 50c90a4328 438 beta release for netbox v370 (#439)
* 3.9.0

* 3.9-beta1
2024-01-02 12:22:46 +01:00
mattieserver 374a88266a used EventRulesMixin (#437) 2024-01-02 11:42:47 +01:00
mattieserver 60cbc94a62 remove imp (#426) 2024-01-02 11:27:46 +01:00
Mario 649305c83e Fixes #416: Images View permissions incorrect (#418)
* Add resize listener and height calculation

* Fixed typos
2023-10-29 14:58:17 +01:00
Mario b3397f4f4a Fixes #417: README is missing some permissions (#419)
* Add resize listener and height calculation

* Fixed typos

* add permissions for new views
2023-10-29 14:56:42 +01:00
Mario a23604261a Update README.md (#415)
Version matrix wasn't in sync with the latest release.
2023-10-29 14:56:19 +01:00
mattieserver d9571b30b5 bump version (#410) 2023-10-23 19:17:22 +02:00
kkthxbye 76b43f5d1e Overwrite the form renderer of DeviceFilterForm to fix regression caused by netbox 3.6.4 (#408) 2023-10-20 06:38:33 +02:00
mattieserver becfaaa054 bump version (#397) 2023-10-13 13:08:18 +02:00
Mario bec301eb9d fix provider network error (#394) 2023-09-28 15:50:04 +02:00
Mario f28623b378 fix image save for old Python versions (#390) 2023-09-28 15:49:05 +02:00
Mario 16837047be fix device filter (#393) 2023-09-28 15:06:04 +02:00
Mario 987c7ef34c Fixes #377: Non-devices do not save coordinates (#387)
* corrected nodeId handling

* add models

* import models, migrate db

* correct migration deps

* load coordinates from db

* add serializers, correct imports

* add views and templates

* fix X-Y swap in help text

* add non-devices to coordinate group view
2023-09-27 09:13:17 +02:00
Mario dbe331afb8 add filters / housekeeping (#384) 2023-09-25 17:28:32 +02:00
Mario 86c0c55896 Closes #381: Filter by site group (#382)
* add Site Group filter

* revise query_params
2023-09-21 16:15:45 +02:00
mattieserver 78b8a5ae97 v3.7.0 (#371) 2023-08-31 13:48:01 +02:00
mattieserver 71e4807cb1 prepare for netbox v3.6.0 release (#366)
* prepare for netbox v3.6.0 release

* updated readme to reflect the version
2023-08-31 12:33:09 +02:00
mattieserver dfbe26318f bump version (#359) 2023-07-28 10:55:55 +02:00
mattieserver debc314714 added permissions (#358) 2023-07-28 10:52:34 +02:00
54 changed files with 3873 additions and 982 deletions
+15
View File
@@ -0,0 +1,15 @@
name: NetBox plugin manifest modified
on:
push:
branches: [ develop ]
paths:
- netbox-plugin.yaml
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
manifest-modified:
uses: netboxlabs/public-workflows/.github/workflows/reusable-plugin-manifest-modified.yml@release
+47 -27
View File
@@ -14,31 +14,15 @@ Options to export to xml (for draw.io/diagrams.net) or png.
## Install
**_NOTE:_** For docker please see: [Docker install](https://github.com/netbox-community/netbox-docker/wiki/Using-Netbox-Plugins)
**_NOTE:_** For Docker please see: [Docker install](https://github.com/netbox-community/netbox-docker/wiki/Using-Netbox-Plugins)
**_NOTE:_** Add `RUN mkdir -p /opt/netbox/netbox/static/netbox_topology_views/img` to the Dockerfile-Plugins file to create the image folder
**_NOTE:_** Add `RUN mkdir -p /opt/netbox/netbox/static/netbox_topology_views/img` to the Dockerfile-Plugins file to create the image folder.
The plugin is available as a Python package and can be installed with pip.
Run `pip install netbox-topology-views` in your virtual env.
To ensure NetBox Topology Views plugin is automatically re-installed during future upgrades, create a file named `local_requirements.txt` (if not already existing) in the NetBox root directory (alongside `requirements.txt`) and list the `netbox-topology-views` package:
```no-highlight
# echo netbox-topology-views >> local_requirements.txt
```
Once installed, the plugin needs to be enabled in your `configuration.py`
```python
# In your configuration.py
PLUGINS = ["netbox_topology_views"]
```
First run `source /opt/netbox/venv/bin/activate` to enter the Python virtual environment.
Then run
Then run:
```bash
cd /opt/netbox/netbox
pip3 install netbox-topology-views
@@ -46,18 +30,45 @@ python3 manage.py migrate netbox_topology_views
python3 manage.py collectstatic --no-input
```
To ensure NetBox Topology Views plugin is automatically re-installed during future upgrades, create a file named `local_requirements.txt` (if not already existing) in the NetBox root directory (alongside `requirements.txt`) and list the `netbox-topology-views` package:
```no-highlight
echo netbox-topology-views >> /opt/netbox/local_requirements.txt
```
Once installed, the plugin needs to be enabled in your `configuration.py`:
```no-highlight
vim /opt/netbox/netbox/netbox/configuration.py
```
```python
PLUGINS = ["netbox_topology_views"]
```
Finally, restart NetBox:
```no-highlight
systemctl restart netbox
```
### Versions
> [!NOTE]
> If the version of netbox is not listed here the plugin will most likely not work. Remove the plugin if you want to update netbox before this plugin has released an update for that netbox version.
| netbox version | netbox-topology-views version |
| -------------- | ----------------------------- |
| >= 3.5.0 | >= v3.4.X |
| >= 3.4.0 | >= v3.X.X |
| >= 3.3.0 | >= v3.0.0 |
| >= 3.2.0 | >= v1.1.0 |
| >= 3.1.8 | >= v1.0.0 |
| >= 2.11.1 | >= v0.5.3 |
| >= 2.10.0 | >= v0.5.0 |
| < 2.10.0 | =< v0.4.10 |
| = 4.5.X | = v4.5.0 |
| = 4.4.X | = v4.4.0 |
| = 4.3.X | = v4.3.0 |
| = 4.2.1 | = v4.2.1 |
| = 4.2.0 | = v4.2.0 |
| = 4.1.X | = v4.1.0 |
| = 4.0.X | = v4.0.0 |
| = 3.7.0 | = v3.9.0 |
| = 3.6.4 | = v3.8.1 |
| = 3.6.0 | = v3.7.0 |
| = 3.5.0 | = v3.6.2 |
### Update
@@ -199,3 +210,12 @@ Set `Coordinate Groups` according to your needs:
Set `Coordinates` according to your needs:
+ netbox_topology_views | coordinate | view/add/change/delete
Set `Power Feed Coordinates` according to your needs:
+ netbox_topology_views | power feed coordinate | view/add/change/delete
Set `Power Panel Coordinates` according to your needs:
+ netbox_topology_views | power panel coordinate | view/add/change/delete
Set `Circuit Coordinates` according to your needs:
+ netbox_topology_views | circuit coordinate | view/add/change/delete
+3 -3
View File
@@ -1,7 +1,7 @@
version: '3.4'
services:
postgres:
image: postgres:12-alpine
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: J5brHrAXFLQSif0K #just for local dev
POSTGRES_DB: netbox
@@ -9,7 +9,7 @@ services:
ports:
- "5432:5432"
volumes:
- netbox-postgres-data:/var/lib/postgresql/data
- netbox-postgres-data-16:/var/lib/postgresql/data
redis:
image: redis:6-alpine
@@ -35,7 +35,7 @@ services:
- "6378:6379"
volumes:
netbox-postgres-data:
netbox-postgres-data-16:
driver: local
netbox-redis-data:
driver: local
+36
View File
@@ -0,0 +1,36 @@
version: 0.1
package_name: netbox-topology-views
compatibility:
- release: 4.5.0
netbox_min: 4.5.0
netbox_max: 4.5.1
- release: 4.4.0
netbox_min: 4.4.0
netbox_max: 4.4.3
- release: 4.3.0
netbox_min: 4.3.0
netbox_max: 4.3.7
- release: 4.2.0
netbox_min: 4.2.0
netbox_max: 4.2.1
- release: 4.1.0
netbox_min: 4.1.0
netbox_max: 4.1.11
- release: 4.0.1
netbox_min: 4.0.9
netbox_max: 4.0.11
- release: 4.0.0
netbox_min: 4.0.0
netbox_max: 4.0.9
- release: 3.9.1
netbox_min: 3.7.1
netbox_max: 3.7.8
- release: 3.9.0
netbox_min: 3.7.1
netbox_max: 3.7.8
- release: 3.8.1
netbox_min: 3.6.9
netbox_max: 3.6.9
- release: 3.6.2
netbox_min: 3.5.1
netbox_max: 3.5.9
+2 -2
View File
@@ -1,11 +1,11 @@
from extras.plugins import PluginConfig
from netbox.plugins import PluginConfig
class TopologyViewsConfig(PluginConfig):
name = "netbox_topology_views"
verbose_name = "Topology views"
description = "An plugin to render topology maps"
version = "3.6.1"
version = "4.5.0"
author = "Mattijs Vanhaverbeke"
author_email = "author@example.com"
base_url = "netbox_topology_views"
+17 -2
View File
@@ -2,7 +2,7 @@ from dcim.models import Device, DeviceRole
from rest_framework.serializers import ModelSerializer
from netbox.api.serializers import NetBoxModelSerializer
from netbox_topology_views.models import RoleImage, IndividualOptions, CoordinateGroup, Coordinate
from netbox_topology_views.models import RoleImage, IndividualOptions, CoordinateGroup, Coordinate, CircuitCoordinate, PowerPanelCoordinate, PowerFeedCoordinate
class TopologyDummySerializer(ModelSerializer):
@@ -32,7 +32,22 @@ class CoordinateSerializer(NetBoxModelSerializer):
model = Coordinate
fields = ("x", "y")
class CircuitCoordinateSerializer(NetBoxModelSerializer):
class Meta:
model = CircuitCoordinate
fields = ("x", "y")
class PowerPanelCoordinateSerializer(NetBoxModelSerializer):
class Meta:
model = PowerPanelCoordinate
fields = ("x", "y")
class PowerFeedCoordinateSerializer(NetBoxModelSerializer):
class Meta:
model = PowerFeedCoordinate
fields = ("x", "y")
class IndividualOptionsSerializer(NetBoxModelSerializer):
class Meta:
model = IndividualOptions
fields = ("ignore_cable_type", "save_coords", "show_unconnected", "show_cables", "show_logical_connections", "show_single_cable_logical_conns", "show_neighbors", "show_circuit", "show_power", "show_wireless", "draw_default_layout")
fields = ("ignore_cable_type", "save_coords", "show_unconnected", "show_cables", "show_logical_connections", "show_single_cable_logical_conns", "show_neighbors", "show_circuit", "show_power", "show_wireless", "group_sites", "group_locations", "group_racks", "group_virtualchassis", "draw_default_layout", "straight_cables", "grid_size", "node_label_items")
+6 -1
View File
@@ -6,6 +6,11 @@ router = NetBoxRouter()
router.register("save-coords", views.SaveCoordsViewSet)
router.register("images", views.SaveRoleImageViewSet)
router.register("xml-export", views.ExportTopoToXML)
router.register("xml-export", views.ExportTopoToXML, "xml-export")
router.register("coordinate-groups", views.CoordinateGroupViewSet)
router.register("coordinate", views.CoordinateViewSet)
router.register("circuitcoordinate", views.CircuitCoordinateViewSet)
router.register("powerpanelcoordinate", views.PowerPanelCoordinateViewSet)
router.register("powerfeedcoordinate", views.PowerFeedCoordinateViewSet)
urlpatterns = router.urls
+111 -26
View File
@@ -1,7 +1,11 @@
from typing import Dict
import sys
from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet
from circuits.models import Circuit
from dcim.models import Device, DeviceRole, PowerFeed, PowerPanel
from extras.models import SavedFilter
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType
@@ -13,13 +17,19 @@ from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
from netbox_topology_views.api.serializers import (
RoleImageSerializer,
TopologyDummySerializer,
CoordinateGroupSerializer,
CoordinateSerializer,
CircuitCoordinateSerializer,
PowerPanelCoordinateSerializer,
PowerFeedCoordinateSerializer,
)
from netbox_topology_views.models import RoleImage, IndividualOptions, CoordinateGroup, Coordinate
import netbox_topology_views.models
from netbox_topology_views.models import RoleImage, IndividualOptions, CoordinateGroup, Coordinate, CircuitCoordinate, PowerPanelCoordinate, PowerFeedCoordinate
from netbox_topology_views.views import get_topology_data
from netbox_topology_views.utils import get_image_from_url, export_data_to_xml, get_query_settings
from netbox_topology_views.filters import DeviceFilterSet
class SaveCoordsViewSet(PermissionRequiredMixin, ReadOnlyModelViewSet):
class SaveCoordsViewSet(BaseViewSet, ReadOnlyModelViewSet):
permission_required = 'netbox_topology_views.change_coordinate'
queryset = Device.objects.none()
@@ -41,20 +51,26 @@ class SaveCoordsViewSet(PermissionRequiredMixin, ReadOnlyModelViewSet):
if device_id.startswith("c"):
device_id = device_id.lstrip("c")
actual_device = Circuit.objects.get(id=device_id)
model_name = 'CircuitCoordinate'
elif device_id.startswith("p"):
device_id = device_id.lstrip("p")
actual_device = PowerPanel.objects.get(id=device_id)
model_name = 'PowerPanelCoordinate'
elif device_id.startswith("f"):
device_id = device_id.lstrip("f")
actual_device = PowerFeed.objects.get(id=device_id)
model_name = 'PowerFeedCoordinate'
elif device_id.isnumeric():
actual_device = Device.objects.get(id=device_id)
model_name = 'Coordinate'
if not actual_device:
return Response({"status": "invalid node_id in body"}, status=400)
model_class = getattr(netbox_topology_views.models, model_name)
if group_id is None or group_id == "default":
group_id = Coordinate.get_or_create_default_group(group_id)
group_id = model_class.get_or_create_default_group(group_id)
if not group_id:
return Response(
{"status": "Error while creating default group."}, status=500
@@ -66,12 +82,12 @@ class SaveCoordsViewSet(PermissionRequiredMixin, ReadOnlyModelViewSet):
# Hen-and-egg-problem. Thanks, Django! By default, Django updates records that
# already exist and inserts otherwise. This does not work with our
# unique_together key if no pk is given. But: No record, no pk.
if not Coordinate.objects.filter(group=group, device=actual_device):
if not model_class.objects.filter(group=group, device=actual_device):
# Unique group/device pair does not exist. Prepare new data set
coords = Coordinate(group=group, device=actual_device, x=x_coord, y=y_coord)
coords = model_class(group=group, device=actual_device, x=x_coord, y=y_coord)
else:
# Unique group/device pair already exists. Update data
coords = Coordinate(pk=Coordinate.objects.get(group=group, device=actual_device).pk, group=group, device=actual_device, x=x_coord, y=y_coord)
coords = model_class(pk=model_class.objects.get(group=group, device=actual_device).pk, group=group, device=actual_device, x=x_coord, y=y_coord)
coords.save()
except:
return Response(
@@ -80,17 +96,15 @@ class SaveCoordsViewSet(PermissionRequiredMixin, ReadOnlyModelViewSet):
return Response({"status": "saved coords"})
class ExportTopoToXML(PermissionRequiredMixin, ViewSet):
permission_required = ("dcim.view_site", "dcim.view_device")
class ExportTopoToXML(BaseViewSet, ViewSet):
queryset = Device.objects.none()
serializer_class = TopologyDummySerializer
def list(self, request):
self.filterset = DeviceFilterSet
self.queryset = Device.objects.all().select_related(
"device_type", "device_role"
self.queryset = Device.objects.restrict(request.user, 'view').select_related(
"device_type", "role"
)
self.queryset = self.filterset(request.GET, self.queryset).qs
@@ -100,14 +114,47 @@ class ExportTopoToXML(PermissionRequiredMixin, ViewSet):
if request.GET:
save_coords, show_unconnected, show_power, show_circuit, show_logical_connections, show_single_cable_logical_conns, show_cables, show_wireless, show_neighbors = get_query_settings(request)
filter_id, ignore_cable_type, save_coords, show_unconnected, show_power, show_circuit, show_logical_connections, show_single_cable_logical_conns, show_cables, show_wireless, group_sites, group_locations, group_racks, group_virtualchassis, group, show_neighbors, straight_cables, grid_size, node_label_items = get_query_settings(request)
# Read options from saved filters as NetBox does not handle custom plugin filters
if "filter_id" in request.GET and request.GET["filter_id"] != '':
try:
saved_filter = SavedFilter.objects.get(pk=filter_id)
saved_filter_params = getattr(saved_filter, 'parameters')
if ignore_cable_type == () and 'ignore_cable_type' in saved_filter_params: ignore_cable_type = saved_filter_params['ignore_cable_type']
if save_coords == False and 'save_coords' in saved_filter_params: save_coords = saved_filter_params['save_coords']
if show_unconnected == False and 'show_unconnected' in saved_filter_params: show_unconnected = saved_filter_params['show_unconnected']
if show_power == False and 'show_power' in saved_filter_params: show_power = saved_filter_params['show_power']
if show_circuit == False and 'show_circuit' in saved_filter_params: show_circuit = saved_filter_params['show_circuit']
if show_logical_connections == False and 'show_logical_connections' in saved_filter_params: show_logical_connections = saved_filter_params['show_logical_connections']
if show_single_cable_logical_conns == False and 'show_single_cable_logical_conns' in saved_filter_params: show_single_cable_logical_conns = saved_filter_params['show_single_cable_logical_conns']
if show_cables == False and 'show_cables' in saved_filter_params: show_cables = saved_filter_params['show_cables']
if show_wireless == False and 'show_wireless' in saved_filter_params: show_wireless = saved_filter_params['show_wireless']
if group_sites == False and 'group_sites' in saved_filter_params: group_sites = saved_filter_params['group_sites']
if group_locations == False and 'group_locations' in saved_filter_params: group_locations = saved_filter_params['group_locations']
if group_racks == False and 'group_racks' in saved_filter_params: group_racks = saved_filter_params['group_racks']
if group_virtualchassis == False and 'group_virtualchassis' in saved_filter_params: group_virtualchassis = saved_filter_params['group_virtualchassis']
if show_neighbors == False and 'show_neighbors' in saved_filter_params: show_neighbors = saved_filter_params['show_neighbors']
if straight_cables == False and 'straight_cables' in saved_filter_params: show_neighbors = saved_filter_params['straight_cables']
if grid_size == 0 and 'grid_size' in saved_filter_params: grid_size = saved_filter_params['grid_size']
if node_label_items == () and 'node_label_items' in saved_filter_params: node_label_items = saved_filter_params['node_label_items']
except SavedFilter.DoesNotExist: # filter_id not found
pass
except Exception as inst:
print(type(inst))
if 'group' not in request.query_params:
group_id = "default"
if 'saved_filter_params' in locals() and "group" in saved_filter_params:
group_id = saved_filter_params['group'][0]
else:
group_id = "default"
else:
group_id = request.query_params["group"]
topo_data = get_topology_data(
queryset=self.queryset,
individualOptions=individualOptions,
ignore_cable_type=ignore_cable_type,
save_coords=save_coords,
show_unconnected=show_unconnected,
show_cables=show_cables,
@@ -117,9 +164,16 @@ class ExportTopoToXML(PermissionRequiredMixin, ViewSet):
show_circuit=show_circuit,
show_power=show_power,
show_wireless=show_wireless,
group_sites=group_sites,
group_locations=group_locations,
group_racks=group_racks,
group_virtualchassis=group_virtualchassis,
group_id=group_id,
straight_cables=straight_cables,
grid_size=grid_size,
node_label_items=node_label_items,
)
xml_data = export_data_to_xml(topo_data).decode('utf-8')
xml_data = export_data_to_xml(topo_data).decode('utf-8').replace('\n', '&#xa;')
return HttpResponse(xml_data, content_type="application/xml; charset=utf-8")
else:
@@ -127,12 +181,12 @@ class ExportTopoToXML(PermissionRequiredMixin, ViewSet):
{"status": "Missing or malformed request parameters"}, status=400
)
class SaveRoleImageViewSet(PermissionRequiredMixin, ReadOnlyModelViewSet):
class SaveRoleImageViewSet(BaseViewSet, ReadOnlyModelViewSet):
queryset = DeviceRole.objects.none()
serializer_class = RoleImageSerializer
permission_required = (
"dcim.add_device_role",
"dcim.change_device_role",
"dcim.add_devicerole",
"dcim.change_devicerole",
)
@action(detail=False, methods=["post"])
@@ -142,15 +196,26 @@ class SaveRoleImageViewSet(PermissionRequiredMixin, ReadOnlyModelViewSet):
{"status": "Missing or malformed request body"}, status=400
)
device_roles = {
k: v.removeprefix(settings.STATIC_URL)
for k, v in request.data.items()
if k.isnumeric()}
content_type_ids = {
k[2:]: v.removeprefix(settings.STATIC_URL)
for k, v in request.data.items()
if k.startswith("ct") and k[2:].isnumeric()
}
if sys.version_info >= (3,9,0):
device_roles = {
k: v.removeprefix(settings.STATIC_URL)
for k, v in request.data.items()
if k.isnumeric()}
content_type_ids = {
k[2:]: v.removeprefix(settings.STATIC_URL)
for k, v in request.data.items()
if k.startswith("ct") and k[2:].isnumeric()
}
else:
device_roles = {}
for k, v in request.data.items():
if k.isdigit() and v.startswith(settings.STATIC_URL):
device_roles[k] = v[len(settings.STATIC_URL):]
content_type_ids = {}
for k, v in request.data.items():
if k.startswith("ct") and k[2:].isdigit() and v.startswith(settings.STATIC_URL):
content_type_ids[k[2:]] = v[len(settings.STATIC_URL):]
roles: Dict[int, DeviceRole] = DeviceRole.objects.in_bulk(device_roles.keys())
content_types: Dict[int, ContentType] = ContentType.objects.in_bulk(
@@ -194,3 +259,23 @@ class SaveRoleImageViewSet(PermissionRequiredMixin, ReadOnlyModelViewSet):
)
return JsonResponse({"status": "Ok"})
class CoordinateGroupViewSet(NetBoxModelViewSet):
queryset = CoordinateGroup.objects.all()
serializer_class = CoordinateGroupSerializer
class CoordinateViewSet(NetBoxModelViewSet):
queryset = Coordinate.objects.all()
serializer_class = CoordinateSerializer
class CircuitCoordinateViewSet(NetBoxModelViewSet):
queryset = CircuitCoordinate.objects.all()
serializer_class = CircuitCoordinateSerializer
class PowerPanelCoordinateViewSet(NetBoxModelViewSet):
queryset = PowerPanelCoordinate.objects.all()
serializer_class = PowerPanelCoordinateSerializer
class PowerFeedCoordinateViewSet(NetBoxModelViewSet):
queryset = PowerFeedCoordinate.objects.all()
serializer_class = PowerFeedCoordinateSerializer
+34
View File
@@ -0,0 +1,34 @@
from utilities.choices import ChoiceSet
class NodeLabelItems(ChoiceSet):
DEVICE_NAME = 'devicename'
DEVICE_TYPE = 'devicetype'
ROLE = 'role'
DESCRIPTION = 'description'
PRIMARY_IPV4 = 'primaryipv4'
PRIMARY_IPV6 = 'primaryipv6'
OUT_OF_BAND_IP = 'outofbandip'
PLATFORM = 'platform'
SERIAL = 'serial'
TENANT = 'tenant'
SITE = 'site'
LOCATION = 'location'
RACK = 'rack'
VIRTUAL_CHASSIS = 'virtualchassis'
CHOICES = [
(DEVICE_NAME, 'Device Name'),
(DEVICE_TYPE, 'Device Type'),
(ROLE, 'Role'),
(DESCRIPTION, 'Description'),
(PRIMARY_IPV4, 'Primary IPv4'),
(PRIMARY_IPV6, 'Primary IPv6'),
(OUT_OF_BAND_IP, 'Out-of-band IP'),
(PLATFORM, 'Platform'),
(SERIAL, 'Serial'),
(TENANT, 'Tenant'),
(SITE, 'Site'),
(LOCATION, 'Location'),
(RACK, 'Rack'),
(VIRTUAL_CHASSIS, 'Virtual Chassis'),
]
+185 -8
View File
@@ -1,28 +1,56 @@
import django_filters
from circuits.models import Circuit
from dcim.choices import DeviceStatusChoices
from dcim.models import Device, DeviceRole, Location, Rack, Region, Site
from dcim.models import Device, DeviceRole, Location, Rack, Region, Site, SiteGroup, Manufacturer, DeviceType, Platform, PowerPanel, PowerFeed
from django.db.models import Q
from extras.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate
from netbox.filtersets import NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter
from netbox_topology_views.models import CoordinateGroup, Coordinate
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter, MultiValueCharFilter, MultiValueMACAddressFilter
from netbox_topology_views.models import CoordinateGroup, Coordinate, CircuitCoordinate, PowerPanelCoordinate, PowerFeedCoordinate
class DeviceFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
q = django_filters.CharFilter(
method="search",
label="Search",
)
device_role_id = django_filters.ModelMultipleChoiceFilter(
field_name="device_role_id",
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__manufacturer',
queryset=Manufacturer.objects.all(),
label="Manufacturer (ID)",
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__manufacturer__slug',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
label="Manufacturer (slug)",
)
device_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceType.objects.all(),
label="Device type (ID)",
)
role_id = django_filters.ModelMultipleChoiceFilter(
field_name="role_id",
queryset=DeviceRole.objects.all(),
label="Role (ID)",
)
platform_id = django_filters.ModelMultipleChoiceFilter(
queryset=Platform.objects.all(),
label="Platform (ID)",
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name="site__region",
lookup_expr="in",
label="Region (ID)",
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name="site__group",
lookup_expr="in",
label="Site Group (ID)",
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label="Site (ID)",
@@ -42,10 +70,57 @@ class DeviceFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
choices=DeviceStatusChoices,
null_value=None,
)
mac_address = MultiValueMACAddressFilter(
field_name='interfaces__mac_addresses__mac_address',
label="MAC address",
)
serial = MultiValueCharFilter(
lookup_expr='iexact'
)
console_ports = django_filters.BooleanFilter(
method='_console_ports',
label="Has console ports",
)
console_server_ports = django_filters.BooleanFilter(
method='_console_server_ports',
label="Has console server ports",
)
power_ports = django_filters.BooleanFilter(
method='_power_ports',
label="Has power ports",
)
power_outlets = django_filters.BooleanFilter(
method='_power_outlets',
label="Has power outlets",
)
interfaces = django_filters.BooleanFilter(
method='_interfaces',
label="Has interfaces",
)
pass_through_ports = django_filters.BooleanFilter(
method='_pass_through_ports',
label="Has pass-through ports",
)
config_template_id = django_filters.ModelMultipleChoiceFilter(
queryset=ConfigTemplate.objects.all(),
label="Config template (ID)",
)
has_primary_ip = django_filters.BooleanFilter(
method='_has_primary_ip',
label="Has a primary IP",
)
has_oob_ip = django_filters.BooleanFilter(
method='_has_oob_ip',
label="Has an out-of-band IP",
)
virtual_chassis_member = django_filters.BooleanFilter(
method='_virtual_chassis_member',
label="Is a virtual chassis member",
)
class Meta:
model = Device
fields = ["id", "name"]
fields = ["id", "name", "asset_tag", "airflow"]
def search(self, queryset, name, value):
"""Perform the filtered search."""
@@ -54,6 +129,108 @@ class DeviceFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
qs_filter = Q(name__icontains=value)
return queryset.filter(qs_filter)
def _console_ports(self, queryset, name, value):
return queryset.exclude(consoleports__isnull=value)
def _console_server_ports(self, queryset, name, value):
return queryset.exclude(consoleserverports__isnull=value)
def _power_ports(self, queryset, name, value):
return queryset.exclude(powerports__isnull=value)
def _power_outlets(self, queryset, name, value):
return queryset.exclude(poweroutlets__isnull=value)
def _interfaces(self, queryset, name, value):
return queryset.exclude(interfaces__isnull=value)
def _pass_through_ports(self, queryset, name, value):
return queryset.exclude(
frontports__isnull=value,
rearports__isnull=value
)
def _has_primary_ip(self, queryset, name, value):
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
if value:
return queryset.filter(params)
return queryset.exclude(params)
def _has_oob_ip(self, queryset, name, value):
params = Q(oob_ip__isnull=False)
if value:
return queryset.filter(params)
return queryset.exclude(params)
def _virtual_chassis_member(self, queryset, name, value):
return queryset.exclude(virtual_chassis__isnull=value)
class CircuitCoordinatesFilterSet(NetBoxModelFilterSet):
group = django_filters.ModelMultipleChoiceFilter(
queryset = CoordinateGroup.objects.all(),
)
device = django_filters.ModelMultipleChoiceFilter(
queryset = Circuit.objects.all(),
)
class Meta:
model = CircuitCoordinate
fields = ['id', 'group', 'device', 'x', 'y']
def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
return queryset.filter(
Q(group__name__icontains=value) |
Q(device__name__icontains=value)
)
class PowerPanelCoordinatesFilterSet(NetBoxModelFilterSet):
group = django_filters.ModelMultipleChoiceFilter(
queryset = CoordinateGroup.objects.all(),
)
device = django_filters.ModelMultipleChoiceFilter(
queryset = PowerPanel.objects.all(),
)
class Meta:
model = PowerPanelCoordinate
fields = ['id', 'group', 'device', 'x', 'y']
def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
return queryset.filter(
Q(group__name__icontains=value) |
Q(device__name__icontains=value)
)
class PowerFeedCoordinatesFilterSet(NetBoxModelFilterSet):
group = django_filters.ModelMultipleChoiceFilter(
queryset = CoordinateGroup.objects.all(),
)
device = django_filters.ModelMultipleChoiceFilter(
queryset = PowerFeed.objects.all(),
)
class Meta:
model = PowerFeedCoordinate
fields = ['id', 'group', 'device', 'x', 'y']
def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
return queryset.filter(
Q(group__name__icontains=value) |
Q(device__name__icontains=value)
)
class CoordinatesFilterSet(NetBoxModelFilterSet):
group = django_filters.ModelMultipleChoiceFilter(
queryset = CoordinateGroup.objects.all(),
+577 -192
View File
@@ -1,157 +1,358 @@
from cProfile import label
import imp
from django import forms
from django.conf import settings
from django.utils.translation import gettext as _
from dcim.models import Device, Site, Region, DeviceRole, Location, Rack
from circuits.models import Circuit
from dcim.models import Device, Site, SiteGroup, Region, DeviceRole, Location, Rack, Manufacturer, DeviceType, Platform, PowerPanel, PowerFeed
from django import forms
from dcim.choices import DeviceStatusChoices
from dcim.choices import DeviceStatusChoices, DeviceAirflowChoices
from tenancy.models import TenantGroup, Tenant
from tenancy.forms import TenancyFilterForm
from extras.models import ConfigTemplate
from extras.forms import LocalConfigContextFilterForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from django.conf import settings
from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm, NetBoxModelImportForm
from utilities.forms.rendering import FieldSet
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
from utilities.forms.fields import (
TagFilterField,
DynamicModelMultipleChoiceField
)
from netbox_topology_views.models import IndividualOptions, CoordinateGroup, Coordinate
from netbox_topology_views.models import IndividualOptions, CoordinateGroup, Coordinate, CircuitCoordinate, PowerPanelCoordinate, PowerFeedCoordinate
from netbox_topology_views.choices import NodeLabelItems
class DeviceFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class DeviceFilterForm(
LocalConfigContextFilterForm,
TenancyFilterForm,
ContactModelFilterForm,
NetBoxModelFilterSetForm
):
model = Device
fieldsets = (
(
None,
(
"q",
"filter_id",
),
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'group', 'ignore_cable_type', 'save_coords', 'show_unconnected', 'show_cables', 'show_logical_connections',
'show_single_cable_logical_conns', 'show_neighbors',
'group_sites', 'group_locations', 'group_racks', 'group_virtualchassis', 'straight_cables', 'grid_size', 'node_label_items', name=_("Options")
),
(
None,
(
"group",
"save_coords",
"show_unconnected",
"show_cables",
"show_logical_connections",
"show_single_cable_logical_conns",
"show_neighbors",
"show_circuit",
"show_power",
"show_wireless",
),
FieldSet(
'show_circuit', 'show_power', 'show_wireless', name=_("Additional filter-independent types (non-devices)")
),
(
None,
(
"tenant_group_id",
"tenant_id",
),
FieldSet('id', name=_("Device")),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_("Location")),
FieldSet('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_("Operation")),
FieldSet('manufacturer_id', 'device_type_id', 'platform_id', name=_("Hardware")),
FieldSet('tenant_group_id', 'tenant_id', name=_("Tenant")),
FieldSet('contact', 'contact_role', 'contact_group', name=_("Contacts")),
FieldSet(
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', name=_("Components")
),
(None, ("region_id", "site_id", "location_id", "rack_id")),
(
None,
(
"device_role_id",
"id",
"status",
),
FieldSet(
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data', name=_("Miscellaneous")
),
(None, ("tag",)),
)
group = forms.ModelChoiceField(
queryset=CoordinateGroup.objects.all(),
required=False,
label=_("Coordinate group"),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), required=False, label=_("Region")
)
device_role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(), required=False, label=_("Device Role")
label=_('Coordinate group'),
)
id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
label=_("Device"),
label=_('Device'),
query_params={
"location_id": "$location_id",
"region_id": "$region_id",
"site_id": "$site_id",
"role_id": "$device_role_id",
'location_id': '$location_id',
'region_id': '$region_id',
'site_group_id': '$site_group_id',
'site_id': '$site_id',
'role_id': '$role_id',
'contact': '$contact',
'contact_role': '$contact_role',
'contact_group': '$contact_group',
},
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site Group'),
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
"region_id": "$region_id",
'region_id': '$region_id',
'group_id': '$site_group_id',
},
label=_("Site"),
label=_('Site'),
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={
"region_id": "$region_id",
"site_id": "$site_id",
'region_id': '$region_id',
'site_group_id': '$site_group_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",
'region_id': '$region_id',
'site_group_id': '$site_group_id',
'site_id': '$site_id',
'location_id': '$location_id',
},
label=_("Rack"),
label=_('Rack'),
)
status = forms.MultipleChoiceField(
choices=DeviceStatusChoices, required=False, label=_("Device Status")
choices=DeviceStatusChoices,
required=False,
label=_('Device Status')
)
role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Role')
)
airflow = forms.MultipleChoiceField(
label=_('Airflow'),
choices=add_blank_choice(DeviceAirflowChoices),
required=False
)
serial = forms.CharField(
label=_('Serial'),
required=False
)
asset_tag = forms.CharField(
label=_('Asset tag'),
required=False
)
mac_address = forms.CharField(
required=False,
label=_('MAC address')
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label=_('Manufacturer')
)
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False,
query_params={
'manufacturer_id': '$manufacturer_id'
},
label=_('Model')
)
platform_id = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
required=False,
null_option='None',
label=_('Platform')
)
console_ports = forms.NullBooleanField(
required=False,
label=_('Has console ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
console_server_ports = forms.NullBooleanField(
required=False,
label=_('Has console server ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
power_ports = forms.NullBooleanField(
required=False,
label=_('Has power ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
power_outlets = forms.NullBooleanField(
required=False,
label=_('Has power outlets'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
interfaces = forms.NullBooleanField(
required=False,
label=_('Has interfaces'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
pass_through_ports = forms.NullBooleanField(
required=False,
label=_('Has pass-through ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
config_template_id = DynamicModelMultipleChoiceField(
queryset=ConfigTemplate.objects.all(),
required=False,
label=_('Config template')
)
has_primary_ip = forms.NullBooleanField(
required=False,
label=_('Has a primary IP'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
has_oob_ip = forms.NullBooleanField(
required=False,
label=_('Has an OOB IP'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
virtual_chassis_member = forms.NullBooleanField(
required=False,
label=_('Virtual chassis member'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
tag = TagFilterField(model)
# options
save_coords = forms.BooleanField(
label=_("Save Coordinates"),
ignore_cable_type = forms.MultipleChoiceField(
label=_('Ignore Termination Types'),
required=False,
disabled=(not settings.PLUGINS_CONFIG["netbox_topology_views"]["allow_coordinates_saving"] or settings.PLUGINS_CONFIG["netbox_topology_views"]["always_save_coordinates"]),
initial=(settings.PLUGINS_CONFIG["netbox_topology_views"]["always_save_coordinates"])
choices=IndividualOptions.CHOICES
)
show_unconnected = forms.BooleanField(
label=_("Show Unconnected"), required=False, initial=False
save_coords = forms.NullBooleanField(
label=_('Save Coordinates'),
required=False,
disabled=(not settings.PLUGINS_CONFIG['netbox_topology_views']['allow_coordinates_saving'] or settings.PLUGINS_CONFIG['netbox_topology_views']['always_save_coordinates']),
initial=(settings.PLUGINS_CONFIG['netbox_topology_views']['always_save_coordinates']),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
show_cables = forms.BooleanField(
label =_("Show Cables"), required=False, initial=False
show_unconnected = forms.NullBooleanField(
label=_('Show Unconnected'),
required=False,
initial=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
show_logical_connections = forms.BooleanField(
label =_("Show Logical Connections"), required=False, initial=False
show_cables = forms.NullBooleanField(
required=False,
initial=False,
label=_('Show Cables'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
show_single_cable_logical_conns = forms.BooleanField(
label =_("Show redundant Cable and Logical Connection"), required=False, initial=False
show_logical_connections = forms.NullBooleanField(
label =_('Show Logical Connections'), required=False, initial=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
show_neighbors = forms.BooleanField(
label =_("Show Neighbors"), required=False, initial=False
show_single_cable_logical_conns = forms.NullBooleanField(
label =_('Show redundant Cable and Logical Connection'), required=False, initial=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
show_circuit = forms.BooleanField(
label=_("Show Circuit Terminations"), required=False, initial=False
show_neighbors = forms.NullBooleanField(
label =_('Show Neighbors'), required=False, initial=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
show_power = forms.BooleanField(
label=_("Show Power Feeds"), required=False, initial=False
group_sites = forms.NullBooleanField(
label =_('Group Sites'), required=False, initial=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
show_wireless = forms.BooleanField(
label =_("Show Wireless Links"), required=False, initial=False
group_locations = forms.NullBooleanField(
label =_('Group Locations'), required=False, initial=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
group_racks = forms.NullBooleanField(
label =_('Group Racks'), required=False, initial=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
group_virtualchassis = forms.NullBooleanField(
label =_('Group Virtual Chassis'), required=False, initial=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
straight_cables = forms.BooleanField(
label=_('Straight Cables'), required=False, initial=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
grid_size = forms.IntegerField(
label=_('Grid Size'),
required=False,
initial=0,
min_value=0,
max_value=1000,
help_text=_('Show grid and snap dragged icons to grid. Set to 0 to disable grid and snapping.')
)
node_label_items = forms.MultipleChoiceField(
label=_('Node Label Items'),
required=False,
choices=NodeLabelItems
)
# Additional types (non-devices)
show_circuit = forms.NullBooleanField(
label=_('Show Circuit Terminations'), required=False, initial=False,
help_text=_('All Circuits will be displayed. Circuits cannot be filtered.'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
show_power = forms.NullBooleanField(
label=_('Show Power Feeds'), required=False, initial=False,
help_text=_('All Power Feeds will be displayed. Power Feeds cannot be filtered.'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
show_wireless = forms.NullBooleanField(
label =_('Show Wireless Links'), required=False, initial=False,
help_text=_('All Wireless Links will be displayed. Wireless Links cannot be filtered.'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
class CoordinateGroupsForm(NetBoxModelForm):
fieldsets = (
('Group Details', ('name', 'description')),
FieldSet('name', 'description', name=_("Group Details")),
)
class Meta:
@@ -163,25 +364,142 @@ class CoordinateGroupsImportForm(NetBoxModelImportForm):
model = CoordinateGroup
fields = ('name', 'description')
class CircuitCoordinatesForm(NetBoxModelForm):
fieldsets = (
FieldSet('group', 'device', 'x', 'y', name=_("Circuit Coordinate")),
)
class Meta:
model = CircuitCoordinate
fields = ('group', 'device', 'x', 'y')
class PowerPanelCoordinatesForm(NetBoxModelForm):
fieldsets = (
FieldSet('group', 'device', 'x', 'y', name=_("Power Panel")),
)
class Meta:
model = PowerPanelCoordinate
fields = ('group', 'device', 'x', 'y')
class PowerFeedCoordinatesForm(NetBoxModelForm):
fieldsets = (
FieldSet('group', 'device', 'x', 'y', name=_("PowerFeed Coordinate")),
)
class Meta:
model = PowerFeedCoordinate
fields = ('group', 'device', 'x', 'y')
class CoordinatesForm(NetBoxModelForm):
fieldsets = (
('Coordinate', ('group', 'device', 'x', 'y')),
FieldSet('group', 'device', 'x', 'y', name=_("Coordinate")),
)
class Meta:
model = Coordinate
fields = ('group', 'device', 'x', 'y')
class CircuitCoordinatesImportForm(NetBoxModelImportForm):
class Meta:
model = CircuitCoordinate
fields = ('group', 'device', 'x', 'y')
class PowerPanelCoordinatesImportForm(NetBoxModelImportForm):
class Meta:
model = PowerPanelCoordinate
fields = ('group', 'device', 'x', 'y')
class PowerFeedCoordinatesImportForm(NetBoxModelImportForm):
class Meta:
model = PowerFeedCoordinate
fields = ('group', 'device', 'x', 'y')
class CoordinatesImportForm(NetBoxModelImportForm):
class Meta:
model = Coordinate
fields = ('group', 'device', 'x', 'y')
class CircuitCoordinatesFilterForm(NetBoxModelFilterSetForm):
model = CircuitCoordinate
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('group', 'device', 'x', 'y', name=_("Circuit Coordinates")),
)
group = forms.ModelMultipleChoiceField(
queryset=CoordinateGroup.objects.all(),
required=False
)
device = DynamicModelMultipleChoiceField(
queryset=Circuit.objects.all(),
required=False
)
x = forms.IntegerField(
required=False
)
y = forms.IntegerField(
required=False
)
class PowerPanelCoordinatesFilterForm(NetBoxModelFilterSetForm):
model = PowerPanelCoordinate
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('group', 'device', 'x', 'y', name=_('PowerPanel Coordinates')),
)
group = forms.ModelMultipleChoiceField(
queryset=CoordinateGroup.objects.all(),
required=False
)
device = DynamicModelMultipleChoiceField(
queryset=PowerPanel.objects.all(),
required=False
)
x = forms.IntegerField(
required=False
)
y = forms.IntegerField(
required=False
)
class PowerFeedCoordinatesFilterForm(NetBoxModelFilterSetForm):
model = Coordinate
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('group', 'device', 'x', 'y', name=_("PowerFeed Coordinates")),
)
group = forms.ModelMultipleChoiceField(
queryset=CoordinateGroup.objects.all(),
required=False
)
device = DynamicModelMultipleChoiceField(
queryset=PowerFeed.objects.all(),
required=False
)
x = forms.IntegerField(
required=False
)
y = forms.IntegerField(
required=False
)
class CoordinatesFilterForm(NetBoxModelFilterSetForm):
model = Coordinate
fieldsets = (
(None, ('q', 'filter_id')),
('Coordinates', ('group', 'device', 'x', 'y'))
FieldSet('q', 'filter_id'),
FieldSet('group', 'device', 'x', 'y', name=_("Coordinates")),
)
group = forms.ModelMultipleChoiceField(
@@ -204,133 +522,200 @@ class CoordinatesFilterForm(NetBoxModelFilterSetForm):
class IndividualOptionsForm(NetBoxModelForm):
fieldsets = (
(
None,
FieldSet
(
"user_id",
"ignore_cable_type",
"preselected_device_roles",
"preselected_tags",
"save_coords",
"show_unconnected",
"show_cables",
"show_logical_connections",
"show_single_cable_logical_conns",
"show_neighbors",
"show_circuit",
"show_power",
"show_wireless",
"draw_default_layout",
'ignore_cable_type',
'preselected_device_roles',
'preselected_tags',
'save_coords',
'show_unconnected',
'show_cables',
'show_logical_connections',
'show_single_cable_logical_conns',
'show_neighbors',
'group_sites',
'group_locations',
'group_racks',
'group_virtualchassis',
'draw_default_layout',
'straight_cables',
'grid_size',
'node_label_items',
),
FieldSet
(
'show_circuit',
'show_power',
'show_wireless',
name=_("Additional filter-independent types (non-devices)")
),
),
)
user_id = forms.CharField(widget=forms.HiddenInput())
ignore_cable_type = forms.MultipleChoiceField(
label=_("Ignore Termination Types"),
required=False,
label=_('Ignore Termination Types'),
required=False,
choices=IndividualOptions.CHOICES,
help_text=_("Choose Termination Types that you want to be ignored. "
"If any ignored Termination Type is part of a connection, the "
"cable is not displayed.")
help_text=_('Choose Termination Types that you want to be ignored. '
'If any ignored Termination Type is part of a connection, the '
'cable is not displayed.')
)
preselected_device_roles = DynamicModelMultipleChoiceField(
label=_("Preselected Device Role"),
label=_('Preselected Device Role'),
queryset=DeviceRole.objects.all(),
required=False,
help_text=_("Select Device Roles that you want to have "
"preselected in the filter tab.")
help_text=_('Select Device Roles that you want to have '
'preselected in the filter tab.')
)
preselected_tags = forms.ModelMultipleChoiceField(
label=_("Preselected Tags"),
label=_('Preselected Tags'),
queryset=Device.tags.all(),
required=False,
help_text=_("Select Tags that you want to have "
"preselected in the filter tab.")
help_text=_('Select Tags that you want to have '
'preselected in the filter tab.')
)
save_coords = forms.BooleanField(
label=_("Save Coordinates"),
required=False,
initial=False,
help_text=_("Coordinates of nodes will be saved if dragged to a different "
"position. This option depends on parameters set in the config file. "
"It has no effect if 'allow_coordinates_saving' has not been set or "
" 'always_save_coordinates' has been set.")
)
show_unconnected = forms.BooleanField(
label=_("Show Unconnected"),
required=False,
initial=False,
help_text=_("Draws devices that have no connections or for which no "
"connection is displayed. This option depends on other parameters "
"like 'Show Cables' and 'Show Logical Connections'.")
)
show_cables = forms.BooleanField(
label =_("Show Cables"),
required=False,
initial=False,
help_text=_("Displays connections between interfaces that are connected "
"with one or more cables. These connections are displayed as solid "
"lines in the color of the cable.")
)
show_logical_connections = forms.BooleanField(
label =_("Show Logical Connections"),
required=False,
initial=False,
help_text=_("Displays connections between devices that are not "
"directly connected (e.g. via patch panels). These connections "
"are displayed as yellow dotted lines.")
)
show_single_cable_logical_conns = forms.BooleanField(
label = ("Show redundant Cable and Logical Connection"),
required = False,
initial=False,
help_text=_("Shows a logical connection (in addition to a cable), "
"even if a cable is directly connected. Leaving this option "
"disabled prevents that redundant display. This option only "
"has an effect if 'Show Logical Connections' is activated.")
)
show_neighbors = forms.BooleanField(
label =_("Show Neighbors"),
required=False,
initial=False,
help_text=_("Adds neighbors to the filter result set automatically. "
"Link peers will be added if 'Show Cables' is ticked, far-end "
"terminations will be added if 'Show Logical Connections' is ticked.")
)
show_circuit = forms.BooleanField(
label=_("Show Circuit Terminations"),
required=False,
initial=False,
help_text=_("Displays connections between circuit terminations. "
"These connections are displayed as blue dashed lines.")
)
show_power = forms.BooleanField(
label=_("Show Power Feeds"),
required=False,
initial=False,
help_text=_("Displays connections between power outlets and power "
"ports. These connections are displayed as solid lines in the "
"color of the cable. This option depends on 'Show Cables'.")
)
show_wireless = forms.BooleanField(
label =_("Show Wireless Links"),
required=False,
initial=False,
help_text=_("Displays wireless connections. These connections are "
"displayed as blue dotted lines.")
)
draw_default_layout = forms.BooleanField(
label = ("Draw Default Layout"),
label=_('Save Coordinates'),
required=False,
initial=False,
help_text=_("Enable this option if you want to draw the topology on "
"the initial load (when you go to the topology plugin page).")
help_text=_('Coordinates of nodes will be saved if dragged to a different '
'position. This option depends on parameters set in the config file. '
'It has no effect if \'allow_coordinates_saving\' has not been set or '
' \'always_save_coordinates\' has been set.')
)
show_unconnected = forms.BooleanField(
label=_('Show Unconnected'),
required=False,
initial=False,
help_text=_('Draws devices that have no connections or for which no '
'connection is displayed. This option depends on other parameters '
'like \'Show Cables\' and \'Show Logical Connections\'.')
)
show_cables = forms.BooleanField(
label =_('Show Cables'),
required=False,
initial=False,
help_text=_('Displays connections between interfaces that are connected '
'with one or more cables. These connections are displayed as solid '
'lines in the color of the cable.')
)
show_logical_connections = forms.BooleanField(
label =_('Show Logical Connections'),
required=False,
initial=False,
help_text=_('Displays connections between devices that are not '
'directly connected (e.g. via patch panels). These connections '
'are displayed as yellow dotted lines.')
)
show_single_cable_logical_conns = forms.BooleanField(
label = ('Show redundant Cable and Logical Connection'),
required = False,
initial=False,
help_text=_('Shows a logical connection (in addition to a cable), '
'even if a cable is directly connected. Leaving this option '
'disabled prevents that redundant display. This option only '
'has an effect if \'Show Logical Connections\' is activated.')
)
show_neighbors = forms.BooleanField(
label =_('Show Neighbors'),
required=False,
initial=False,
help_text=_('Adds neighbors to the filter result set automatically. '
'Link peers will be added if \'Show Cables\' is ticked, far-end '
'terminations will be added if \'Show Logical Connections\' is ticked.')
)
group_sites = forms.BooleanField(
label =_('Group Sites'),
required=False,
initial=False,
help_text=_('Draws a rectangle around Devices that belong to the '
'same site.')
)
group_locations = forms.BooleanField(
label =_('Group Locations'),
required=False,
initial=False,
help_text=_('Draws a rectangle around Devices that belong to the '
'same location.')
)
group_racks = forms.BooleanField(
label =_('Group Racks'),
required=False,
initial=False,
help_text=_('Draws a rectangle around Devices that belong to the '
'same rack.')
)
group_virtualchassis = forms.BooleanField(
label =_('Group Virtual Chassis'),
required=False,
initial=False,
help_text=_('Draws a rectangle around Devices that belong to the '
'same virtual chassis.')
)
draw_default_layout = forms.BooleanField(
label = ('Draw Default Layout'),
required=False,
initial=False,
help_text=_('Enable this option if you want to draw the topology on '
'the initial load (when you go to the topology plugin page).')
)
straight_cables = forms.BooleanField(
label=('Use Straight Cables'),
required=False,
initial=False,
help_text=_('Enable this option if you want to draw cables as straight lines '
'instead of curves.')
)
grid_size = forms.IntegerField(
label=_('Grid Size'),
required=True,
initial=0,
min_value=0,
max_value=1000,
help_text=_('Default grid value. Set to 0 to disable grid. '
'Integers between 0 and 1000 are allowed. Snap to grid will be '
'automatically enabled for values > 0.')
)
node_label_items = forms.MultipleChoiceField(
label=_('Node Label Items'),
required=False,
choices=NodeLabelItems,
help_text=_('Choose Label Items that you want to be displayed.')
)
show_circuit = forms.BooleanField(
label=_('Show Circuit Terminations'),
required=False,
initial=False,
help_text=_('Displays connections between circuit terminations. '
'These connections are displayed as blue dashed lines. '
'Be aware that Circuits cannot be filtered.')
)
show_power = forms.BooleanField(
label=_('Show Power Feeds'),
required=False,
initial=False,
help_text=_('Displays connections between power outlets and power '
'ports. These connections are displayed as solid lines in the '
'color of the cable. This option depends on \'Show Cables\'. '
'Be aware that Power Feeds cannot be filtered.')
)
show_wireless = forms.BooleanField(
label =_('Show Wireless Links'),
required=False,
initial=False,
help_text=_('Displays wireless connections. These connections are '
'displayed as blue dotted lines. '
'Be aware that Wireless Links cannot be filtered.')
)
class Meta:
model = IndividualOptions
fields = [
'user_id', 'ignore_cable_type', 'preselected_device_roles', 'preselected_tags', 'save_coords', 'show_unconnected', 'show_cables', 'show_logical_connections', 'show_single_cable_logical_conns', 'show_neighbors', 'show_circuit', 'show_power', 'show_wireless', 'draw_default_layout'
]
'user_id', 'ignore_cable_type', 'preselected_device_roles', 'preselected_tags',
'save_coords', 'show_unconnected', 'show_cables', 'show_logical_connections',
'show_single_cable_logical_conns', 'show_neighbors',
'group_sites', 'group_locations', 'group_racks', 'group_virtualchassis',
'draw_default_layout', 'straight_cables', 'grid_size', 'node_label_items',
'show_circuit', 'show_power', 'show_wireless'
]
@@ -0,0 +1,70 @@
# Generated by Django 4.1.8 on 2023-09-25 21:18
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
import utilities.json
class Migration(migrations.Migration):
dependencies = [
('netbox_topology_views', '0005_individualoptions_save_coords'),
]
operations = [
migrations.CreateModel(
name='PowerPanelCoordinate',
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)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
('x', models.IntegerField()),
('y', models.IntegerField()),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.powerpanel')),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='netbox_topology_views.coordinategroup')),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'ordering': ['group', 'device'],
'unique_together': {('device', 'group')},
},
),
migrations.CreateModel(
name='PowerFeedCoordinate',
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)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
('x', models.IntegerField()),
('y', models.IntegerField()),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.powerfeed')),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='netbox_topology_views.coordinategroup')),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'ordering': ['group', 'device'],
'unique_together': {('device', 'group')},
},
),
migrations.CreateModel(
name='CircuitCoordinate',
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)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
('x', models.IntegerField()),
('y', models.IntegerField()),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='circuits.circuit')),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='netbox_topology_views.coordinategroup')),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'ordering': ['group', 'device'],
'unique_together': {('device', 'group')},
},
),
]
@@ -0,0 +1,28 @@
# Generated by Django 4.2.10 on 2024-03-02 16:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('netbox_topology_views', '0006_powerpanelcoordinate_powerfeedcoordinate_and_more'),
]
operations = [
migrations.AddField(
model_name='individualoptions',
name='group_locations',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='individualoptions',
name='group_racks',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='individualoptions',
name='group_sites',
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.10 on 2024-04-29 14:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('netbox_topology_views', '0007_individualoptions_group_locations_and_more'),
]
operations = [
migrations.AddField(
model_name='individualoptions',
name='straight_cables',
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-07-29 08:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('netbox_topology_views', '0008_straight_cables'),
]
operations = [
migrations.AddField(
model_name='individualoptions',
name='group_virtualchassis',
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-08-14 09:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('netbox_topology_views', '0009_individualoptions_group_virtualchassis'),
]
operations = [
migrations.AddField(
model_name='individualoptions',
name='grid_size',
field=models.PositiveSmallIntegerField(default=0),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.9 on 2024-09-27 18:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('netbox_topology_views', '0010_individualoptions_grid_size'),
]
operations = [
migrations.AddField(
model_name='individualoptions',
name='node_label_items',
field=models.CharField(blank=True, max_length=255),
),
]
+196 -11
View File
@@ -1,7 +1,8 @@
from pathlib import Path
from typing import Optional
from dcim.models import Device, DeviceRole
from circuits.models import Circuit
from dcim.models import Device, DeviceRole, PowerPanel, PowerFeed
from extras.models import Tag
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
@@ -12,7 +13,7 @@ from netbox.models import NetBoxModel
from netbox.models.features import (
ChangeLoggingMixin,
ExportTemplatesMixin,
WebhooksMixin,
EventRulesMixin,
)
from netbox_topology_views.utils import (
@@ -24,8 +25,9 @@ from netbox_topology_views.utils import (
image_static_url,
)
from netbox_topology_views.choices import NodeLabelItems
class RoleImage(ChangeLoggingMixin, ExportTemplatesMixin, WebhooksMixin):
class RoleImage(ChangeLoggingMixin, ExportTemplatesMixin, EventRulesMixin):
class Meta:
indexes = [
models.Index(fields=["content_type", "object_id"]),
@@ -40,6 +42,8 @@ class RoleImage(ChangeLoggingMixin, ExportTemplatesMixin, WebhooksMixin):
__role: Optional[Role] = None
_netbox_private = True
@property
def role(self) -> Role:
if self.__role:
@@ -110,6 +114,8 @@ class CoordinateGroup(NetBoxModel):
blank = True,
)
_netbox_private = True
class Meta:
ordering = ['name']
@@ -130,13 +136,15 @@ class Coordinate(NetBoxModel):
x = models.IntegerField(
help_text='X-coordinate of the device (horizontal) on the canvas. '
'Smaller values correspond to a position further up on the monitor.',
'Smaller values correspond to a position further to the left on the monitor.',
)
y = models.IntegerField(
help_text='Y-coordinate of the device (vertical) on the canvas. '
'Smaller values correspond to a position further to the left on the monitor.',
'Smaller values correspond to a position further up on the monitor.',
)
_netbox_private = True
def get_or_create_default_group(group_id):
# Default group named "default" must always exist in order to make sure
# that coordinate values can be stored even if no coordinate group has been
@@ -168,15 +176,168 @@ class Coordinate(NetBoxModel):
def get_absolute_url(self):
return reverse('plugins:netbox_topology_views:coordinate', args=[self.pk])
class CircuitCoordinate(NetBoxModel):
"""
Coordinates are being used to place devices in a topology view onto a certain
position. Devices belong to one or more coordinate groups. They have to
be unique together.
"""
device = models.ForeignKey(Circuit, on_delete=models.CASCADE)
group = models.ForeignKey(CoordinateGroup, on_delete=models.CASCADE)
x = models.IntegerField(
help_text='X-coordinate of the device (horizontal) on the canvas. '
'Smaller values correspond to a position further to the left on the monitor.',
)
y = models.IntegerField(
help_text='Y-coordinate of the device (vertical) on the canvas. '
'Smaller values correspond to a position further up on the monitor.',
)
_netbox_private = True
def get_or_create_default_group(group_id):
# Default group named "default" must always exist in order to make sure
# that coordinate values can be stored even if no coordinate group has been
# selected. The default group will be added automatically if it does not exist.
try:
if CoordinateGroup.objects.filter(name="default"):
group = CoordinateGroup.objects.get(name="default")
group_id = group.pk
else:
group = CoordinateGroup(
name="default",
description="Automatically generated default group. If you delete "
"this group, all default coordinates are gone for good but "
"the group itself will be re-created."
)
group.save()
group_id = group.pk
except:
return False
return group_id
class Meta:
ordering = ['group', 'device']
unique_together = ('device', 'group')
def __str__(self):
return f'{self.x};{self.y}'
def get_absolute_url(self):
return reverse('plugins:netbox_topology_views:circuitcoordinate', args=[self.pk])
class PowerPanelCoordinate(NetBoxModel):
"""
Coordinates are being used to place devices in a topology view onto a certain
position. Devices belong to one or more coordinate groups. They have to
be unique together.
"""
device = models.ForeignKey(PowerPanel, on_delete=models.CASCADE)
group = models.ForeignKey(CoordinateGroup, on_delete=models.CASCADE)
x = models.IntegerField(
help_text='X-coordinate of the device (horizontal) on the canvas. '
'Smaller values correspond to a position further to the left on the monitor.',
)
y = models.IntegerField(
help_text='Y-coordinate of the device (vertical) on the canvas. '
'Smaller values correspond to a position further up on the monitor.',
)
_netbox_private = True
def get_or_create_default_group(group_id):
# Default group named "default" must always exist in order to make sure
# that coordinate values can be stored even if no coordinate group has been
# selected. The default group will be added automatically if it does not exist.
try:
if CoordinateGroup.objects.filter(name="default"):
group = CoordinateGroup.objects.get(name="default")
group_id = group.pk
else:
group = CoordinateGroup(
name="default",
description="Automatically generated default group. If you delete "
"this group, all default coordinates are gone for good but "
"the group itself will be re-created."
)
group.save()
group_id = group.pk
except:
return False
return group_id
class Meta:
ordering = ['group', 'device']
unique_together = ('device', 'group')
def __str__(self):
return f'{self.x};{self.y}'
def get_absolute_url(self):
return reverse('plugins:netbox_topology_views:powerpanelcoordinate', args=[self.pk])
class PowerFeedCoordinate(NetBoxModel):
"""
Coordinates are being used to place devices in a topology view onto a certain
position. Devices belong to one or more coordinate groups. They have to
be unique together.
"""
device = models.ForeignKey(PowerFeed, on_delete=models.CASCADE)
group = models.ForeignKey(CoordinateGroup, on_delete=models.CASCADE)
x = models.IntegerField(
help_text='X-coordinate of the device (horizontal) on the canvas. '
'Smaller values correspond to a position further to the left on the monitor.',
)
y = models.IntegerField(
help_text='Y-coordinate of the device (vertical) on the canvas. '
'Smaller values correspond to a position further up on the monitor.',
)
_netbox_private = True
def get_or_create_default_group(group_id):
# Default group named "default" must always exist in order to make sure
# that coordinate values can be stored even if no coordinate group has been
# selected. The default group will be added automatically if it does not exist.
try:
if CoordinateGroup.objects.filter(name="default"):
group = CoordinateGroup.objects.get(name="default")
group_id = group.pk
else:
group = CoordinateGroup(
name="default",
description="Automatically generated default group. If you delete "
"this group, all default coordinates are gone for good but "
"the group itself will be re-created."
)
group.save()
group_id = group.pk
except:
return False
return group_id
class Meta:
ordering = ['group', 'device']
unique_together = ('device', 'group')
def __str__(self):
return f'{self.x};{self.y}'
def get_absolute_url(self):
return reverse('plugins:netbox_topology_views:powerfeedcoordinate', args=[self.pk])
class IndividualOptions(NetBoxModel):
CHOICES = (
('interface', 'interface'),
('front port', 'front port'),
('rear port', 'rear port'),
('power outlet', 'power outlet'),
('power port', 'power port'),
('console port', 'console port'),
('console server port', 'console server port'),
('frontport', 'frontport'),
('rearport', 'rearport'),
('poweroutlet', 'poweroutlet'),
('powerport', 'powerport'),
('consoleport', 'consoleport'),
('consoleserverport', 'consoleserverport'),
)
user_id = models.IntegerField(
@@ -226,9 +387,33 @@ class IndividualOptions(NetBoxModel):
show_wireless = models.BooleanField(
default=False
)
group_sites = models.BooleanField(
default=False
)
group_locations = models.BooleanField(
default=False
)
group_racks = models.BooleanField(
default=False
)
group_virtualchassis = models.BooleanField(
default=False
)
draw_default_layout = models.BooleanField(
default=False
)
straight_cables = models.BooleanField(
default=False
)
grid_size = models.PositiveSmallIntegerField(
default=0
)
node_label_items = models.CharField(
max_length=255,
blank=True
)
_netbox_private = True
def __str___(self):
return f"{self.user_id}"
+62 -11
View File
@@ -1,18 +1,62 @@
from extras.plugins import PluginMenu, PluginMenuItem, PluginMenuButton
from utilities.choices import ButtonColorChoices
from netbox.plugins import PluginMenu, PluginMenuItem, PluginMenuButton
coordinategroup_buttons = (
PluginMenuButton(
link='plugins:netbox_topology_views:coordinategroup_add',
title='Add',
icon_class='mdi mdi-plus-thick',
color=ButtonColorChoices.GREEN,
permissions=['netbox_topology_views.add_coordinategroup']
),
PluginMenuButton(
link='plugins:netbox_topology_views:coordinategroup_import',
title='Import',
icon_class='mdi mdi-upload',
color=ButtonColorChoices.CYAN,
permissions=['netbox_topology_views.add_coordinategroup']
)
)
circuitcoordinate_buttons = (
PluginMenuButton(
link='plugins:netbox_topology_views:circuitcoordinate_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=['netbox_topology_views.add_coordinate']
),
PluginMenuButton(
link='plugins:netbox_topology_views:circuitcoordinate_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=['netbox_topology_views.add_coordinate']
)
)
powerpanelcoordinate_buttons = (
PluginMenuButton(
link='plugins:netbox_topology_views:powerpanelcoordinate_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=['netbox_topology_views.add_coordinate']
),
PluginMenuButton(
link='plugins:netbox_topology_views:powerpanelcoordinate_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=['netbox_topology_views.add_coordinate']
)
)
powerfeedcoordinate_buttons = (
PluginMenuButton(
link='plugins:netbox_topology_views:powerfeedcoordinate_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=['netbox_topology_views.add_coordinate']
),
PluginMenuButton(
link='plugins:netbox_topology_views:powerfeedcoordinate_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=['netbox_topology_views.add_coordinate']
)
)
@@ -21,13 +65,13 @@ coordinate_buttons = (
link='plugins:netbox_topology_views:coordinate_add',
title='Add',
icon_class='mdi mdi-plus-thick',
color=ButtonColorChoices.GREEN,
permissions=['netbox_topology_views.add_coordinate']
),
PluginMenuButton(
link='plugins:netbox_topology_views:coordinate_import',
title='Import',
icon_class='mdi mdi-upload',
color=ButtonColorChoices.CYAN,
permissions=['netbox_topology_views.add_coordinate']
)
)
@@ -37,15 +81,22 @@ menu = PluginMenu(
groups=(
('TOPOLOGY',
(
PluginMenuItem(link="plugins:netbox_topology_views:home", link_text="Topology"),
PluginMenuItem(link="plugins:netbox_topology_views:coordinategroup_list", link_text="Coordinate Groups", buttons=coordinategroup_buttons),
PluginMenuItem(link="plugins:netbox_topology_views:coordinate_list", link_text="Coordinates", buttons=coordinate_buttons),
PluginMenuItem(link="plugins:netbox_topology_views:home", link_text="Topology", permissions=["dcim.view_site", "dcim.view_device"]),
),
),
('COORDINATES',
(
PluginMenuItem(link="plugins:netbox_topology_views:coordinategroup_list", link_text="Coordinate Groups", buttons=coordinategroup_buttons, permissions=['netbox_topology_views.view_coordinategroup']),
PluginMenuItem(link="plugins:netbox_topology_views:coordinate_list", link_text="Device Coordinates", buttons=coordinate_buttons, permissions=['netbox_topology_views.view_coordinate']),
PluginMenuItem(link="plugins:netbox_topology_views:powerfeedcoordinate_list", link_text="Power Feed Coords", buttons=powerfeedcoordinate_buttons, permissions=['netbox_topology_views.view_coordinate']),
PluginMenuItem(link="plugins:netbox_topology_views:powerpanelcoordinate_list", link_text="Power Panel Coords", buttons=powerpanelcoordinate_buttons, permissions=['netbox_topology_views.view_coordinate']),
PluginMenuItem(link="plugins:netbox_topology_views:circuitcoordinate_list", link_text="Circuit Coordinates", buttons=circuitcoordinate_buttons, permissions=['netbox_topology_views.view_coordinate']),
),
),
('PREFERENCES',
(
PluginMenuItem(link="plugins:netbox_topology_views:images", link_text="Images"),
PluginMenuItem(link="plugins:netbox_topology_views:individualoptions", link_text="Individual Options"),
PluginMenuItem(link="plugins:netbox_topology_views:images", link_text="Images", permissions=[ "dcim.view_site","dcim.view_devicerole"]),
PluginMenuItem(link="plugins:netbox_topology_views:individualoptions", link_text="Individual Options", permissions=['netbox_topology_views.change_individualoptions']),
),
),
),
@@ -1 +1 @@
#visgraph{height:64vh}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}
#visgraph{height:64vh;border:1px solid #ced4da}html[data-netbox-color-mode=dark] #visgraph{background-color:#212529;border:1px solid #495057}.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
File diff suppressed because one or more lines are too long
@@ -1 +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("/"+basePath+"api/plugins/netbox_topology_views/images/save/",{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}" />`)})});})();
(()=>{var w=(o,t)=>()=>(o&&(t=o(o=0)),t);var f=(o,t)=>()=>(t||o((t={exports:{}}).exports,t),t.exports);var l=(o,t,e)=>new Promise((r,s)=>{var c=n=>{try{a(e.next(n))}catch(i){s(i)}},g=n=>{try{a(e.throw(n))}catch(i){s(i)}},a=n=>n.done?r(n.value):Promise.resolve(n.value).then(c,g);a((e=e.apply(o,t)).next())});var u,d=w(()=>{u={success:o=>{let t=document.querySelector("#topology-plugin-success-toast");if(!t)return console.error("Could not find toast component!");let e=t.querySelector("span");e.textContent=o,new window.Toast(t).show()},error:o=>{let t=document.querySelector("#topology-plugin-error-toast");if(!t)return console.error("Could not find toast component!");let e=t.querySelector("span");e.textContent=o,new window.Toast(t).show()}}});var y=f(p=>{d();var m={};document.querySelector("form#images").addEventListener("submit",o=>l(null,null,function*(){o.preventDefault();try{let t=yield fetch("/"+basePath+"api/plugins/netbox_topology_views/images/save/",{method:"POST",body:JSON.stringify(m),headers:{"X-CSRFToken":window.CSRF_TOKEN,"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(o=>{o.addEventListener("click",t=>{var c;if(!(t.currentTarget instanceof HTMLElement))return;let{dataset:{role:e,image:r}}=t.currentTarget;m[e]=r;let s=(c=t.currentTarget.closest(".dropdown"))==null?void 0:c.querySelector(`#dropdownMenuButton${e}`);s&&(s.innerHTML=`<img src="${r}" />`)})})});y();})();
@@ -1,6 +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,IAAM,SAAW,iDAAkD,CACvF,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": []
"sources": ["../../../static_dev/js/toast.js", "../../../static_dev/js/images.js"],
"mappings": "oTAAA,IAAaA,EAAbC,EAAAC,EAAA,KAAaF,EAAQ,CACjB,QAAUG,GAAY,CAClB,IAAMC,EAAK,SAAS,cAAc,gCAAgC,EAClE,GAAI,CAACA,EAAI,OAAO,QAAQ,MAAM,iCAAiC,EAC/D,IAAMC,EAAUD,EAAG,cAAc,MAAM,EACvCC,EAAQ,YAAcF,EACR,IAAI,OAAO,MAAMC,CAAE,EAC3B,KAAK,CACf,EACA,MAAQD,GAAY,CAChB,IAAMC,EAAK,SAAS,cAAc,8BAA8B,EAChE,GAAI,CAACA,EAAI,OAAO,QAAQ,MAAM,iCAAiC,EAC/D,IAAMC,EAAUD,EAAG,cAAc,MAAM,EACvCC,EAAQ,YAAcF,EACR,IAAI,OAAO,MAAMC,CAAE,EAC3B,KAAK,CACf,CACJ,ICjBA,IAAAE,EAAAC,EAAAC,GAAA,CAAAC,IAEA,IAAMC,EAAU,CAAC,EAEjB,SAAS,cAAc,aAAa,EAAE,iBAAiB,SAAiBC,GAAMC,EAAA,sBAC1ED,EAAE,eAAe,EACjB,GAAI,CACA,IAAME,EAAM,MAAM,MAAM,IAAM,SAAW,iDAAkD,CACvF,OAAQ,OACR,KAAM,KAAK,UAAUH,CAAO,EAC5B,QAAS,CACL,cAAe,OAAO,WACtB,eAAgB,kBACpB,CACJ,CAAC,EAED,GAAI,CAACG,EAAI,GAAI,MAAM,IAAI,MAAM,MAAMA,EAAI,KAAK,CAAC,EAC7CC,EAAM,QAAQ,gBAAgB,CAClC,OAASC,EAAK,CACV,QAAQ,IAAIA,CAAG,EACfD,EAAM,MAAMC,EAAI,OAAO,CAC3B,CACJ,EAAC,EAED,SAAS,iBAAiB,gCAAgC,EAAE,QAASC,GAAO,CACxEA,EAAG,iBAAiB,QAAUL,GAAM,CAzBxC,IAAAM,EA0BQ,GAAI,EAAEN,EAAE,yBAAyB,aAAc,OAC/C,GAAM,CACF,QAAS,CAAE,KAAAO,EAAM,MAAAC,CAAM,CAC3B,EAAIR,EAAE,cAEND,EAAQQ,CAAI,EAAIC,EAEhB,IAAMC,GAASH,EAAAN,EAAE,cACZ,QAAQ,WAAW,IADT,YAAAM,EAET,cAAc,sBAAsBC,CAAI,IAC1CE,IAAQA,EAAO,UAAY,aAAaD,CAAK,OACrD,CAAC,CACL,CAAC",
"names": ["toast", "init_toast", "__esmMin", "message", "el", "content", "require_images", "__commonJSMin", "exports", "init_toast", "mapping", "e", "__async", "res", "toast", "err", "el", "_a", "role", "image", "button"]
}
@@ -1,12 +1,13 @@
#visgraph {
height: 64vh;
border: 1px solid #ced4da
}
html[data-netbox-color-mode=dark] #visgraph {
background-color: #212529;
border: 1px solid #495057
}
.image-dropdown img {
width: 64px;
height: 64px;
@@ -1,17 +0,0 @@
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
}
+374 -16
View File
@@ -1,6 +1,5 @@
import { DataSet } from 'vis-data/esnext'
import { Network } from 'vis-network/esnext'
import { getCookie } from './csrftoken.js'
const options = {
interaction: {
@@ -36,9 +35,6 @@ const options = {
}
}
// Load CSRF token
const csrftoken = getCookie('csrftoken')
// Render vis graph
let graph = null // vis graph instance
@@ -69,23 +65,166 @@ const coordSaveCheckbox = document.querySelector('#id_save_coords')
title: htmlTitle(node.title)
}))
)
const group_sites = topologyData.options.group_sites
const group_locations = topologyData.options.group_locations
const group_racks = topologyData.options.group_racks
const group_virtualchassis = topologyData.options.group_virtualchassis
const gridSize = parseInt(topologyData.options.grid_size[0]);
var dragMode = false;
graph = new Network(container, { nodes, edges }, options)
graph.fit()
function getGridPosition(nodeId, gridSize) {
x = graph.getPosition(nodeId).x;
y = graph.getPosition(nodeId).y;
if(x >= 0) {
if((x % gridSize) > (gridSize / 2)) {
x += gridSize;
}
}
else {
if((-x % gridSize) > (gridSize / 2)) {
x -= gridSize;
}
}
x = x - x % gridSize;
if(y >= 0) {
if((y % gridSize) > (gridSize / 2)) {
y += gridSize;
}
}
else {
if((-y % gridSize) > (gridSize / 2)) {
y -= gridSize;
}
}
y = y - y % gridSize;
return {
x: x,
y: y
};
}
function drawGrid(canvascontext) {
// Canvas can be zoomed. It then contains more or less virtual pixels than the real number of pixels
const zoomFactor = graph.getScale() * window.devicePixelRatio;
const virtualWidth = canvascontext.canvas.width / zoomFactor;
const virtualHeight = canvascontext.canvas.height / zoomFactor;
// Canvas can be moved. Get the center of the virtual canvas. Take the grid into account
const virtualCenter = graph.getViewPosition();
const rasterizedCenterX = virtualCenter.x - virtualCenter.x % gridSize;
const rasterizedCenterY = virtualCenter.y - virtualCenter.y % gridSize;
// Calculate virtual space for the grid
const hSpace = (virtualWidth / 2) - (virtualWidth / 2) % gridSize + gridSize;
const vSpace = (virtualHeight / 2) - (virtualHeight / 2) % gridSize + gridSize;
// Calculate virtual position for the grid
const left = rasterizedCenterX - gridSize - hSpace;
const right = rasterizedCenterX + gridSize + hSpace;
const top = rasterizedCenterY - gridSize - vSpace;
const bottom = rasterizedCenterY + gridSize + vSpace;
// Draw grid
canvascontext.beginPath();
for (let x = left; x < right; x += gridSize) {
canvascontext.moveTo(x, top);
canvascontext.lineTo(x, bottom);
}
for (let y = top; y < bottom; y += gridSize) {
canvascontext.moveTo(left, y);
canvascontext.lineTo(right, y);
}
canvascontext.strokeStyle = '#777777';
canvascontext.stroke();
}
function drawGridSnapHint(canvascontext) {
// Draw grid hinting line and circle
if(gridSize > 0 && dragMode == true && graph.getSelectedNodes().length > 0) {
for(i = 0; i < graph.getSelectedNodes().length; i++) {
id = graph.getSelectedNodes()[i];
if(window.nodes.get(id).x != graph.getPosition(id).x || window.nodes.get(id).y != graph.getPosition(id).y) {
pos = getGridPosition(graph.getSelectedNodes()[i], gridSize);
canvascontext.beginPath();
canvascontext.arc(graph.getPosition(graph.getSelectedNodes()[i]).x, graph.getPosition(graph.getSelectedNodes()[i]).y, 5, 0, 2 * Math.PI);
canvascontext.fillStyle = '#FF3D3D';
canvascontext.fill();
canvascontext.beginPath();
canvascontext.moveTo(graph.getPosition(graph.getSelectedNodes()[i]).x, graph.getPosition(graph.getSelectedNodes()[i]).y);
canvascontext.lineTo(pos.x, pos.y);
canvascontext.strokeStyle = '#FF3D3D';
canvascontext.stroke();
canvascontext.beginPath();
canvascontext.arc(pos.x, pos.y, 10, 0, 2 * Math.PI);
canvascontext.fillStyle = '#9C0000';
canvascontext.fill();
}
}
}
}
graph.on('dragStart', (params) => {
dragMode = true;
})
graph.on('dragEnd', (params) => {
if (coordSaveCheckbox == null) return
if (!coordSaveCheckbox.checked) return
dragMode = false;
// Place icon on the grid
if(gridSize > 0 && graph.getSelectedNodes().length > 0) {
for(i = 0; i < graph.getSelectedNodes().length; i++) {
id = graph.getSelectedNodes()[i];
if(window.nodes.get(id).x != graph.getPosition(id).x || window.nodes.get(id).y != graph.getPosition(id).y) {
pos = getGridPosition(graph.getSelectedNodes()[i], gridSize);
window.nodes.update({id: graph.getSelectedNodes()[i], x: pos.x, y: pos.y});
}
}
}
if (coordSaveCheckbox.options[coordSaveCheckbox.selectedIndex].text != "Yes") return
Promise.allSettled(
Object.entries(graph.getPositions(params.nodes)).map(
async ([nodeId, nodePosition]) => {
window.nodes.update({id: parseInt(nodeId), physics: false, x: nodePosition.x, y: nodePosition.y});
if(!isNaN(parseInt(nodeId))) {
nodeKey = parseInt(nodeId);
}
else {
nodeKey = nodeId;
}
try {
window.nodes.update({id: nodeKey, physics: false, x: nodePosition.x, y: nodePosition.y});
}
catch (e) {
console.log([
'Error while executing window.nodes.update()',
'nodeId: ' + nodeId,
'nodeKey: ' + nodeKey,
'x: ' + nodePosition.x,
'y: ' + nodePosition.y
]);
console.log(e);
}
const res = await fetch(
'/' + basePath + 'api/plugins/netbox_topology_views/save-coords/save_coords/',
{
method: 'PATCH',
headers: {
'X-CSRFToken': csrftoken,
'X-CSRFToken': window.CSRF_TOKEN,
Accept: 'application/json',
'Content-Type': 'application/json'
},
@@ -97,8 +236,6 @@ const coordSaveCheckbox = document.querySelector('#id_save_coords')
})
}
)
console.log(nodeId, res.status, res.statusText)
}
)
)
@@ -116,6 +253,227 @@ const coordSaveCheckbox = document.querySelector('#id_save_coords')
})
}
})
graph.on('beforeDrawing', (canvascontext) => {
if (gridSize > 0) {
drawGrid(canvascontext);
}
})
graph.on('afterDrawing', (canvascontext) => {
allRectangles = [];
if(group_sites != null && group_sites == 'on') { drawGroupRectangles(canvascontext, groupedNodeSites, siteRectParams); }
if(group_locations != null && group_locations == 'on') { drawGroupRectangles(canvascontext, groupedNodeLocations, locationRectParams); }
if(group_racks != null && group_racks == 'on') { drawGroupRectangles(canvascontext, groupedNodeRacks, rackRectParams); }
if(group_virtualchassis != null && group_virtualchassis == 'on') { drawGroupRectangles(canvascontext, groupedNodeVirtualchassis, virtualchassisRectParams); }
drawGridSnapHint(canvascontext);
})
graph.on('click', (canvascontext) => {
allRectangles.forEach(key => {
// Is the mouse pointer inside of the current rectangle?
if(canvascontext.pointer.canvas.x > (key.x1 - key.border / 2 - 3) && canvascontext.pointer.canvas.x < (key.x2 + key.border / 2 + 3)
&& canvascontext.pointer.canvas.y > (key.y1 - key.border / 2 - 3) && canvascontext.pointer.canvas.y < (key.y2 + key.border / 2 + 3)) {
// We just want to react when the border has been clicked, not the whole rectangle
if (canvascontext.pointer.canvas.x < (key.x1 + key.border / 2 + 3) || canvascontext.pointer.canvas.x > (key.x2 - key.border / 2 - 3)
|| canvascontext.pointer.canvas.y < (key.y1 + key.border / 2 + 3) || canvascontext.pointer.canvas.y > (key.y2 - key.border / 2 - 3)) {
// Generate an array of affected nodes in order to pass it to the select.Nodes() function
let arr = [];
if(key.category == "Site") {
groupedNodeSites.forEach(subArray => {
subArray.forEach(element => {
if (element[1] == key.id) {
arr.push(element[0]);
}
});
});
}
if(key.category == "Location") {
groupedNodeLocations.forEach(subArray => {
subArray.forEach(element => {
if (element[1] === key.id) {
arr.push(element[0]);
}
});
});
}
if(key.category == "Rack") {
groupedNodeRacks.forEach(subArray => {
subArray.forEach(element => {
if (element[1] === key.id) {
arr.push(element[0]);
}
});
});
}
if(key.category == "Virtual Chassis") {
groupedNodeVirtualchassis.forEach(subArray => {
subArray.forEach(element => {
if (element[1] === key.id) {
arr.push(element[0]);
}
});
});
}
graph.selectNodes(arr);
}
}
});
})
// Add information on which node belongs to which group (site/location/rack/virtualchassis).
// Create an array for each group in order to loop through that arrays later
function combineNodeInfo(typeId, type) {
let nodesArray = [];
// Extract node ids and node type ids from all nodes
for (let [key, value] of nodes._data) {
if (value[typeId] != undefined) {
nodesArray.push([value.id, value[typeId], value[type], value.label_num_lines]);
}
}
// Split single array above into arrays grouped by node id
let groupedNodeArray = nodesArray.reduce((acc, value) => {
let key = value[1]; // node id
acc[key] = acc[key] || [];
acc[key].push(value);
return acc;
}, {});
return Object.values(groupedNodeArray);
}
var allRectangles = [];
/* Draw a single rectangle with given parameters
rectangle expects an object that consists of the following keys:
ctx: canvas context on which the rectangle should be drawn
x: x-coordinate of top left point of the rectangle
y: y-coordinate of top left point of the rectangle
width: width of rectangle
height: height of rectangle
lineWidth: border width
color: border color
text: a string to be placed where you want it to be
textPaddingX: x-position of the text
textPaddingY: y-position of the text
font: text font */
function drawGroupRectangle(rectangle) {
// Draw rectangle
rectangle.ctx.beginPath();
rectangle.ctx.lineWidth = rectangle.lineWidth;
rectangle.ctx.strokeStyle = rectangle.color;
rectangle.ctx.rect(rectangle.x, rectangle.y, rectangle.width, rectangle.height);
rectangle.ctx.stroke();
// Draw text
rectangle.ctx.font = rectangle.font;
rectangle.ctx.fillStyle = rectangle.color;
rectangle.ctx.fillText(rectangle.text, rectangle.x + rectangle.textPaddingX, rectangle.y + rectangle.textPaddingY);
allRectangles.push({category: rectangle.category, id: rectangle.id, x1: rectangle.x, y1: rectangle.y, x2: rectangle.x + rectangle.width, y2: rectangle.y + rectangle.height, border: rectangle.lineWidth})
}
/* Draw all rectangles of a given group (site/location//virtualchassis)
rectParams expects an object that consists of the following keys:
lineWidth: border width (string)
color: border color (string)
paddingX: rectangle x-padding, calculated from the center of a node (int)
paddingY: rectangle y-padding, calculated from the center of a node (int)
textPaddingX: text x-padding, calculated from the lower left point of the text (int)
textPaddingY: text y-padding, calculated from the lower left point of the text (int)
font: css-like font size and font (string) */
function drawGroupRectangles(canvascontext, groupedNodes, rectParams) {
for(let value of Object.entries(groupedNodes)) {
const rectangles = [];
const xValues = [];
const yValues = [];
const yValuesWithLabel = [];
const labelLineHeight = 16;
// Get coordinates for every node in a given group
for(let val of value[1]) {
xValues.push(graph.getPosition(val[0]).x);
yValues.push(graph.getPosition(val[0]).y);
yValuesWithLabel.push(yValues[yValues.length -1] + val[3] * labelLineHeight);
}
const minX = Math.min(...xValues);
const maxX = Math.max(...xValues);
const minY = Math.min(...yValues);
const maxY = Math.max(...yValuesWithLabel);
const rectX = minX - rectParams.paddingX;
const rectY = minY - rectParams.paddingY;
const rectSizeX = maxX - minX + 2*rectParams.paddingX;
const rectSizeY = maxY - minY + 2*rectParams.paddingY;
rectangles.push({
ctx: canvascontext,
x: rectX,
y: rectY,
width: rectSizeX,
height: rectSizeY,
lineWidth: rectParams.lineWidth,
color: rectParams.color,
text: value[1][0][2],
textPaddingX: rectParams.textPaddingX,
textPaddingY: rectParams.textPaddingY,
font: rectParams.font,
id: value[1][0][1],
category: rectParams.category
});
rectangles.forEach(function(rectangle) {
drawGroupRectangle(rectangle);
});
}
}
let groupedNodeSites = combineNodeInfo('site_id', 'site');
let siteRectParams = {
lineWidth: "5",
color: "red",
paddingX: 84,
paddingY: 84,
textPaddingX: 8,
textPaddingY: -8,
font: "14px helvetica",
category: "Site"
}
let groupedNodeLocations = combineNodeInfo('location_id', 'location');
let locationRectParams = {
lineWidth: "5",
color: "#337ab7",
paddingX: 77,
paddingY: 77,
textPaddingX: 22,
textPaddingY: 29,
font: "14px helvetica",
category: "Location"
}
let groupedNodeRacks = combineNodeInfo('rack_id', 'rack');
let rackRectParams = {
lineWidth: "5",
color: "green",
paddingX: 70,
paddingY: 70,
textPaddingX: 15,
textPaddingY: 36,
font: "14px helvetica",
category: "Rack"
}
let groupedNodeVirtualchassis = combineNodeInfo('virtual_chassis_id', 'virtual_chassis');
let virtualchassisRectParams = {
lineWidth: "5",
color: "orange",
paddingX: 63,
paddingY: 63,
textPaddingX: 8,
textPaddingY: 43,
font: "14px helvetica",
category: "Virtual Chassis"
}
})()
// Download Graph
@@ -157,12 +515,12 @@ function performXmlDownload() {
if (curr_url.includes(sites_prefix)) {
var site_id = curr_url.split(sites_prefix)[1];
site_id = site_id.split('/')[0]
xml_search_options = 'site_id=' + site_id + '&show_cables=on&show_unconnected=on'
xml_search_options = 'site_id=' + site_id + '&show_cables=True&show_unconnected=True'
}
else if (curr_url.includes(location_prefix)) {
var location_id = curr_url.split(location_prefix)[1];
location_id = location_id.split('/')[0]
xml_search_options = 'location_id=' + location_id + '&show_cables=on&show_unconnected=on'
xml_search_options = 'location_id=' + location_id + '&show_cables=True&show_unconnected=True'
}
}
else {
@@ -191,17 +549,17 @@ const observer = new MutationObserver((mutations) =>
if (
!graph ||
mutation.type !== 'attributes' ||
mutation.attributeName !== 'data-netbox-color-mode' ||
mutation.attributeName !== 'data-bs-theme' ||
!(mutation.target instanceof HTMLElement)
)
return
const { netboxColorMode } = mutation.target.dataset
const netboxColorMode = mutation.target.dataset.bsTheme
options.nodes.font.color = netboxColorMode === 'dark' ? '#fff' : '#000'
graph.setOptions(options)
})
)
observer.observe(document.documentElement, {
observer.observe(document.body, {
attributes: true,
attributeFilter: ['data-netbox-color-mode']
attributeFilter: ['data-bs-theme']
})
@@ -1,8 +1,6 @@
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()
@@ -11,7 +9,7 @@ document.querySelector('form#images').addEventListener('submit', async (e) => {
method: 'POST',
body: JSON.stringify(mapping),
headers: {
'X-CSRFToken': csrftoken,
'X-CSRFToken': window.CSRF_TOKEN,
'Content-Type': 'application/json'
}
})
File diff suppressed because it is too large Load Diff
@@ -1,22 +1,22 @@
{
"private": true,
"name": "netbox_topology_views",
"version": "3.6.1",
"version": "4.5.0",
"scripts": {
"bundle": "node bundle.js",
"bundle:styles": "node bundle.js --styles",
"bundle:scripts": "node bundle.js --scripts"
},
"dependencies": {
"vis-data": "^7.1.6",
"vis-network": "^9.1.6",
"vis-util": "^5.0.3"
"vis-data": "^7.1.9",
"vis-network": "^9.1.9",
"vis-util": "^5.0.7"
},
"devDependencies": {
"@egjs/hammerjs": "^2.0.0",
"component-emitter": "^1.3.0",
"esbuild": "^0.12.24",
"esbuild-sass-plugin": "^1.5.2",
"esbuild": "^0.25.0",
"esbuild-sass-plugin": "^3.3.1",
"keycharm": "^0.4.0",
"timsort": "^0.3.0",
"uuid": "^8.0.0"
+43 -2
View File
@@ -1,7 +1,7 @@
import django_tables2 as tables
from netbox.tables import NetBoxTable, ChoiceFieldColumn
from netbox_topology_views.models import CoordinateGroup, Coordinate
from netbox_topology_views.models import CoordinateGroup, Coordinate, CircuitCoordinate, PowerPanelCoordinate, PowerFeedCoordinate
class CoordinateGroupListTable(NetBoxTable):
name = tables.Column(
@@ -14,6 +14,48 @@ class CoordinateGroupListTable(NetBoxTable):
fields = ('pk', 'id', 'name', 'description', 'devices')
default_columns = ('name', 'description', 'devices')
class CircuitCoordinateListTable(NetBoxTable):
group = tables.Column(
linkify=True
)
device = tables.Column(
linkify=True
)
class Meta(NetBoxTable.Meta):
model = CircuitCoordinate
fields = ('pk', 'id', 'group', 'device', 'x', 'y')
default_columns = ('id', 'group', 'device', 'x', 'y')
class PowerPanelCoordinateListTable(NetBoxTable):
group = tables.Column(
linkify=True
)
device = tables.Column(
linkify=True
)
class Meta(NetBoxTable.Meta):
model = PowerPanelCoordinate
fields = ('pk', 'id', 'group', 'device', 'x', 'y')
default_columns = ('id', 'group', 'device', 'x', 'y')
class PowerFeedCoordinateListTable(NetBoxTable):
group = tables.Column(
linkify=True
)
device = tables.Column(
linkify=True
)
class Meta(NetBoxTable.Meta):
model = PowerFeedCoordinate
fields = ('pk', 'id', 'group', 'device', 'x', 'y')
default_columns = ('id', 'group', 'device', 'x', 'y')
class CoordinateListTable(NetBoxTable):
group = tables.Column(
linkify=True
@@ -27,4 +69,3 @@ class CoordinateListTable(NetBoxTable):
model = Coordinate
fields = ('pk', 'id', 'group', 'device', 'x', 'y')
default_columns = ('id', 'group', 'device', 'x', 'y')
-20
View File
@@ -1,20 +0,0 @@
from extras.plugins import PluginTemplateExtension
from django.conf import settings
from packaging import version
NETBOX_CURRENT_VERSION = version.parse(settings.VERSION)
class SiteButtons(PluginTemplateExtension):
model = 'dcim.site'
def buttons(self):
return self.render('netbox_topology_views/site_button.html')
class LocationButtons(PluginTemplateExtension):
model = 'dcim.location'
def buttons(self):
return self.render('netbox_topology_views/location_button.html')
template_extensions = [SiteButtons, LocationButtons]
@@ -0,0 +1,42 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block title %}Topology Views Coordinates{% endblock title %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Coordinates
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Group</th>
<td>{{ object.group|linkify }}</td>
</tr>
<tr>
<th scope="row">Circuit</th>
<td>{{ object.device|linkify }}</td>
</tr>
<tr>
<th scope="row">X-Coordinate</th>
<td>{{ object.x }}</td>
</tr>
<tr>
<th scope="row">Y-Coordinate</th>
<td>{{ object.y }}</td>
</tr>
</table>
</div>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
{% endblock content %}
@@ -0,0 +1,3 @@
{% extends 'generic/object_edit.html' %}
{% block title %}Add new Circuit Coordinates{% endblock title %}
@@ -0,0 +1,3 @@
{% extends 'generic/object_edit.html' %}
{% block title %}Edit Circuit Coordinates{% endblock title %}
@@ -0,0 +1,3 @@
{% extends 'generic/object_list.html' %}
{% block title %}Circuit Coordinates{% endblock title %}
@@ -32,10 +32,34 @@
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Coordinates</h5>
<h5 class="card-header">Circuit Coordinates</h5>
<div class="card-body table-responsive">
{% render_table circuitcoordinates_table %}
</div>
</div>
</div>
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Power Panel Coordinates</h5>
<div class="card-body table-responsive">
{% render_table powerpanelcoordinates_table %}
</div>
</div>
</div>
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Power Feed Coordinates</h5>
<div class="card-body table-responsive">
{% render_table powerfeedcoordinates_table %}
</div>
</div>
</div>
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Device Coordinates</h5>
<div class="card-body table-responsive">
{% render_table coordinates_table %}
</div>
@@ -1,44 +0,0 @@
{% load static %}
<link rel="stylesheet" href="{% static 'netbox_topology_views/css/vendor.css' %}">
<link rel="stylesheet" href="{% static 'netbox_topology_views/css/app.css' %}">
<div class="modal-header">
<h5 class="modal-title">Topology Views</h5>
<div class="controls">
<div class="control-group">
{% block extra_controls %}{% endblock %}
<a id="btnDownloadXml" class="btn btn-sm btn-info" href="#">
<i class="mdi mdi-download"></i>
Download XML
</a>
<a id="btnDownloadImage" class="btn btn-sm btn-info" href="#">
<i class="mdi mdi-download"></i>
Download PNG
</a>
<a class="btn btn-sm btn-danger" data-bs-dismiss="modal">
<i class="mdi mdi-close-thick"></i>
Close
</a>
</div>
</div>
</div>
<div class="panel-body" >
<div style="max-width: 900px; height: 500px;">
<div id="visgraph" style="width: 100%; height: 100%;">
</div>
</div>
</div>
<style type="text/css">
.modal-dialog {
max-width: 902px !important;
}
</style>
<script type="text/javascript">
var is_htmx = true;
var brokenImage = '{{ broken_image }}';
var topologyData = {{ topology_data | safe }};
</script>
<script src="{% static 'netbox_topology_views/js/app.js' %}?ver={{ epoch }} " defer></script>
@@ -10,7 +10,7 @@
<link rel="stylesheet" href="{% static 'netbox_topology_views/css/app.css' %}">
{% endblock %}
{% block content-wrapper %}
{% block content %}
<form id="images" class="container py-4">
<div class="d-flex flex-wrap justify-content-center gap-3 mb-2">
{% for role in roles %}
@@ -39,7 +39,7 @@
<button class="btn btn-primary" type="submit">Save</button>
</form>
{% include 'netbox_topology_views/toasts.html' %}
{% endblock content-wrapper %}
{% endblock content %}
{% block javascript %}
<script type="text/javascript">
@@ -1,4 +1,4 @@
{% extends 'base/layout.html' %}
{% extends 'generic/_base.html' %}
{% load buttons %}
{% load render_table from django_tables2 %}
{% load static %}
@@ -16,11 +16,11 @@
<div class="controls">
<div class="control-group">
{% block extra_controls %}{% endblock %}
<a id="btnDownloadXml" class="btn btn-sm btn-info" href="#">
<a id="btnDownloadXml" class="btn btn-info" href="#">
<i class="mdi mdi-download"></i>
Download XML
</a>
<a id="btnDownloadImage" class="btn btn-sm btn-info" href="#">
<a id="btnDownloadImage" class="btn btn-info" href="#">
<i class="mdi mdi-download"></i>
Download PNG
</a>
@@ -29,7 +29,7 @@
{% endblock controls %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<ul class="nav nav-tabs" id="tabswrapper">
{% block tab_items %}
<li class="nav-item" role="presentation">
<button class="nav-link active" id="network-tab" data-bs-toggle="tab" data-bs-target="#networks" type="button" role="tab" aria-controls="filters-form" aria-selected="true">
@@ -48,17 +48,26 @@
</ul>
{% endblock tabs %}
{% block content-wrapper %}
{% block content %}
{% with config=settings.PLUGINS_CONFIG.netbox_topology_views %}
<div class="tab-content">
<div class="tab-content" id="tabcontentwrapper">
{# Applied filters #}
{% if filter_form %}
{% applied_filters model filter_form request.GET %}
<div id="filterwrapper">
{% applied_filters model filter_form request.GET %}
</div>
{% endif %}
<div class="tab-pane show active" id="networks" role="tabpanel" aria-labelledby="network-tab">
<div class="panel-body">
{% if filter_required %}
<div class="bg-warning text-white p-2">Nothing to display! Set filters and hit search button or set "Draw Default Layout" in your individual options.</div>
{% endif %}
{% if empty_result %}
<div class="bg-warning text-white p-2">Empty result set! Please review your filter settings.</div>
<div class="bg-info text-white p-2">Hint: Some options may produce an empty result. Use at least "Show Unconnected/Cables/Logical Connections".</div>
{% endif %}
<div id="visgraph"></div>
</div>
</div>
@@ -71,13 +80,42 @@
</div>
{% endwith %}
{% endblock content-wrapper %}
{% endblock content %}
{% block javascript %}
<script type="text/javascript">
const brokenImage = '{{ broken_image }}';
const topologyData = {{ topology_data | safe }};
const basePath = '{{ basepath }}';
</script>
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
function resizeCanvas() {
heightToSubtract = getElementHeight('.page-header') +
getElementHeight('#tabswrapper') +
getElementHeight('#filterwrapper') +
getElementHeight('.footer') +
getElementPadding('#tabcontentwrapper');
document.getElementById("visgraph").style.height = "calc(100vh - " + heightToSubtract + "px)";
}
function getElementPadding(element) {
el = document.querySelector(element);
els = getComputedStyle(el);
padding = parseInt(els.paddingTop) + parseInt(els.paddingBottom);
return padding;
}
function getElementHeight(element) {
el = document.querySelector(element);
els = getComputedStyle(el);
height = el.offsetHeight + parseInt(els.marginTop) + parseInt(els.marginBottom);
return height;
}
</script>
<script src="{% static 'netbox_topology_views/js/app.js' %}" defer></script>
{% endblock javascript %}
@@ -1,4 +1,4 @@
<a role="button" class="btn btn-sm btn-primary" title="Topology" href="#" hx-get="/plugins/netbox_topology_views/topology/?location_id={{ object.id }}&show_cables=on&show_unconnected=on" hx-target="#htmx-modal-content" data-bs-toggle="modal" data-bs-target="#htmx-modal">
<a role="button" class="btn btn-primary" title="Topology" href="#" hx-get="/plugins/netbox_topology_views/topology/?location_id={{ object.id }}&show_cables=True&show_unconnected=True" hx-target="#htmx-modal-content" data-bs-toggle="modal" data-bs-target="#htmx-modal">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
Topology
</a>
@@ -0,0 +1,42 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block title %}Topology Views Coordinates{% endblock title %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Coordinates
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Group</th>
<td>{{ object.group|linkify }}</td>
</tr>
<tr>
<th scope="row">Power Feed</th>
<td>{{ object.device|linkify }}</td>
</tr>
<tr>
<th scope="row">X-Coordinate</th>
<td>{{ object.x }}</td>
</tr>
<tr>
<th scope="row">Y-Coordinate</th>
<td>{{ object.y }}</td>
</tr>
</table>
</div>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
{% endblock content %}
@@ -0,0 +1,3 @@
{% extends 'generic/object_edit.html' %}
{% block title %}Add new Power Feed Coordinates{% endblock title %}
@@ -0,0 +1,3 @@
{% extends 'generic/object_edit.html' %}
{% block title %}Edit Power Feed Coordinates{% endblock title %}
@@ -0,0 +1,3 @@
{% extends 'generic/object_list.html' %}
{% block title %}Power Feed Coordinates{% endblock title %}
@@ -0,0 +1,42 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block title %}Topology Views Coordinates{% endblock title %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Coordinates
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Group</th>
<td>{{ object.group|linkify }}</td>
</tr>
<tr>
<th scope="row">Power Panel</th>
<td>{{ object.device|linkify }}</td>
</tr>
<tr>
<th scope="row">X-Coordinate</th>
<td>{{ object.x }}</td>
</tr>
<tr>
<th scope="row">Y-Coordinate</th>
<td>{{ object.y }}</td>
</tr>
</table>
</div>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
{% endblock content %}
@@ -0,0 +1,3 @@
{% extends 'generic/object_edit.html' %}
{% block title %}Add new Power Panel Coordinates{% endblock title %}
@@ -0,0 +1,3 @@
{% extends 'generic/object_edit.html' %}
{% block title %}Edit Power Panel Coordinates{% endblock title %}
@@ -0,0 +1,3 @@
{% extends 'generic/object_list.html' %}
{% block title %}Power Panel Coordinates{% endblock title %}
@@ -1,4 +0,0 @@
<a role="button" class="btn btn-sm btn-primary" title="Topology" href="#" hx-get="/plugins/netbox_topology_views/topology/?site_id={{ object.id }}&show_cables=on&show_unconnected=on" hx-target="#htmx-modal-content" data-bs-toggle="modal" data-bs-target="#htmx-modal">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
Topology
</a>
+27
View File
@@ -20,6 +20,33 @@ urlpatterns = (
path("coordinate-groups/<int:pk>/delete/", views.CoordinateGroupDeleteView.as_view(), name="coordinategroup_delete"),
path("coordinate-groups/<int:pk>/changelog/", ObjectChangeLogView.as_view(), name="coordinategroup_changelog", kwargs={'model': models.CoordinateGroup}),
# Circuit Coordinate
path("circuitcoordinate/", views.CircuitCoordinateListView.as_view(), name="circuitcoordinate_list"),
path("circuitcoordinate/add/", views.CircuitCoordinateAddView.as_view(), name="circuitcoordinate_add"),
path('circuitcoordinate/import/', views.CircuitCoordinateBulkImportView.as_view(), name='circuitcoordinate_import'),
path("circuitcoordinate/<int:pk>/", views.CircuitCoordinateView.as_view(), name="circuitcoordinate"),
path("circuitcoordinate/<int:pk>/edit/", views.CircuitCoordinateEditView.as_view(), name="circuitcoordinate_edit"),
path("circuitcoordinate/<int:pk>/delete/", views.CircuitCoordinateDeleteView.as_view(), name="circuitcoordinate_delete"),
path("circuitcoordinate/<int:pk>/changelog/", ObjectChangeLogView.as_view(), name="circuitcoordinate_changelog", kwargs={'model': models.CircuitCoordinate}),
# Power Panel Coordinate
path("powerpanelcoordinate/", views.PowerPanelCoordinateListView.as_view(), name="powerpanelcoordinate_list"),
path("powerpanelcoordinate/add/", views.PowerPanelCoordinateAddView.as_view(), name="powerpanelcoordinate_add"),
path('powerpanelcoordinate/import/', views.PowerPanelCoordinateBulkImportView.as_view(), name='powerpanelcoordinate_import'),
path("powerpanelcoordinate/<int:pk>/", views.PowerPanelCoordinateView.as_view(), name="powerpanelcoordinate"),
path("powerpanelcoordinate/<int:pk>/edit/", views.PowerPanelCoordinateEditView.as_view(), name="powerpanelcoordinate_edit"),
path("powerpanelcoordinate/<int:pk>/delete/", views.PowerPanelCoordinateDeleteView.as_view(), name="powerpanelcoordinate_delete"),
path("powerpanelcoordinate/<int:pk>/changelog/", ObjectChangeLogView.as_view(), name="powerpanelcoordinate_changelog", kwargs={'model': models.PowerPanelCoordinate}),
# Power Feed Coordinate
path("powerfeedcoordinate/", views.PowerFeedCoordinateListView.as_view(), name="powerfeedcoordinate_list"),
path("powerfeedcoordinate/add/", views.PowerFeedCoordinateAddView.as_view(), name="powerfeedcoordinate_add"),
path('powerfeedcoordinate/import/', views.PowerFeedCoordinateBulkImportView.as_view(), name='powerfeedcoordinate_import'),
path("powerfeedcoordinate/<int:pk>/", views.PowerFeedCoordinateView.as_view(), name="powerfeedcoordinate"),
path("powerfeedcoordinate/<int:pk>/edit/", views.PowerFeedCoordinateEditView.as_view(), name="powerfeedcoordinate_edit"),
path("powerfeedcoordinate/<int:pk>/delete/", views.PowerFeedCoordinateDeleteView.as_view(), name="powerfeedcoordinate_delete"),
path("powerfeedcoordinate/<int:pk>/changelog/", ObjectChangeLogView.as_view(), name="powerfeedcoordinate_changelog", kwargs={'model': models.PowerFeedCoordinate}),
# Coordinate
path("coordinate/", views.CoordinateListView.as_view(), name="coordinate_list"),
path("coordinate/add/", views.CoordinateAddView.as_view(), name="coordinate_add"),
+56 -11
View File
@@ -107,9 +107,13 @@ def get_model_role(model: Type[Model]) -> Role:
)
def get_query_settings(request):
filter_id = ''
if "filter_id" in request.GET:
filter_id = request.GET["filter_id"]
save_coords = False
if "save_coords" in request.GET:
if request.GET["save_coords"] == "on":
if request.GET["save_coords"] == "True":
save_coords = True
# General options overrides
if save_coords == True and settings.PLUGINS_CONFIG["netbox_topology_views"]["allow_coordinates_saving"] == False:
@@ -119,47 +123,88 @@ def get_query_settings(request):
save_coords = True
# Individual options
ignore_cable_type = ()
if "ignore_cable_type" in request.GET:
ignore_cable_type = request.GET.getlist('ignore_cable_type')
show_unconnected = False
if "show_unconnected" in request.GET:
if request.GET["show_unconnected"] == "on":
if request.GET["show_unconnected"] == "True":
show_unconnected = True
show_power = False
if "show_power" in request.GET:
if request.GET["show_power"] == "on":
if request.GET["show_power"] == "True":
show_power = True
show_circuit = False
if "show_circuit" in request.GET:
if request.GET["show_circuit"] == "on":
if request.GET["show_circuit"] == "True":
show_circuit = True
show_logical_connections = False
if "show_logical_connections" in request.GET:
if request.GET["show_logical_connections"] == "on" :
if request.GET["show_logical_connections"] == "True" :
show_logical_connections = True
show_single_cable_logical_conns = False
if "show_single_cable_logical_conns" in request.GET:
if request.GET["show_single_cable_logical_conns"] == "on" :
if request.GET["show_single_cable_logical_conns"] == "True" :
show_single_cable_logical_conns = True
show_cables = False
if "show_cables" in request.GET:
if request.GET["show_cables"] == "on" :
if request.GET["show_cables"] == "True" :
show_cables = True
show_wireless = False
if "show_wireless" in request.GET:
if request.GET["show_wireless"] == "on" :
if request.GET["show_wireless"] == "True" :
show_wireless = True
group_sites = False
if "group_sites" in request.GET:
if request.GET["group_sites"] == "True" :
group_sites = True
group_locations = False
if "group_locations" in request.GET:
if request.GET["group_locations"] == "True" :
group_locations = True
group_racks = False
if "group_racks" in request.GET:
if request.GET["group_racks"] == "True" :
group_racks = True
group_virtualchassis = False
if "group_virtualchassis" in request.GET:
if request.GET["group_virtualchassis"] == "True" :
group_virtualchassis = True
group = False
if "group" in request.GET:
group = request.GET["group"]
show_neighbors = False
if "show_neighbors" in request.GET:
if request.GET["show_neighbors"] == "on" :
if request.GET["show_neighbors"] == "True" :
show_neighbors = True
return save_coords, show_unconnected, show_power, show_circuit, show_logical_connections, show_single_cable_logical_conns, show_cables, show_wireless, show_neighbors
straight_cables = False
if "straight_cables" in request.GET:
if request.GET["straight_cables"] == "True":
straight_cables = True
grid_size = 0
if "grid_size" in request.GET:
grid_size = request.GET.getlist('grid_size')
node_label_items = ()
if "node_label_items" in request.GET:
node_label_items = request.GET.getlist('node_label_items')
return filter_id, ignore_cable_type, save_coords, show_unconnected, show_power, show_circuit, show_logical_connections, show_single_cable_logical_conns, show_cables, show_wireless, group_sites, group_locations, group_racks, group_virtualchassis, group, show_neighbors, straight_cables, grid_size, node_label_items
class LinePattern():
wireless = [2, 10, 2, 10]
+362 -75
View File
@@ -1,11 +1,9 @@
import json
from functools import reduce
from typing import DefaultDict, Dict, Optional, Union
import time
from itertools import chain
from utilities.htmx import is_htmx
from circuits.models import Circuit, CircuitTermination
from circuits.models import Circuit, CircuitTermination, ProviderNetwork
from dcim.models import (
Cable,
CableTermination,
@@ -27,7 +25,7 @@ from django.db.models.functions import Lower
from django.http import HttpRequest, HttpResponseRedirect, QueryDict
from django.shortcuts import render, get_object_or_404
from django.views.generic import View
from extras.models import Tag
from extras.models import Tag, SavedFilter
from wireless.models import WirelessLink
from netbox.views.generic import (
ObjectView,
@@ -37,20 +35,36 @@ from netbox.views.generic import (
ObjectChangeLogView,
BulkImportView
)
from netbox_topology_views.filters import DeviceFilterSet, CoordinatesFilterSet
from netbox_topology_views.filters import DeviceFilterSet, CoordinatesFilterSet, CircuitCoordinatesFilterSet, PowerPanelCoordinatesFilterSet, PowerFeedCoordinatesFilterSet
from netbox_topology_views.forms import (
DeviceFilterForm,
IndividualOptionsForm,
CoordinateGroupsForm,
CircuitCoordinatesForm,
CircuitCoordinatesFilterForm,
CircuitCoordinatesImportForm,
PowerPanelCoordinatesForm,
PowerPanelCoordinatesFilterForm,
PowerPanelCoordinatesImportForm,
PowerFeedCoordinatesForm,
PowerFeedCoordinatesFilterForm,
PowerFeedCoordinatesImportForm,
CoordinatesForm,
CoordinatesFilterForm,
CoordinateGroupsImportForm,
CoordinatesImportForm
)
from netbox_topology_views.models import RoleImage, CoordinateGroup, Coordinate, IndividualOptions
from netbox_topology_views.tables import CoordinateGroupListTable, CoordinateListTable
import netbox_topology_views.models
from netbox_topology_views.models import (
RoleImage,
IndividualOptions,
CoordinateGroup,
Coordinate,
CircuitCoordinate,
PowerPanelCoordinate,
PowerFeedCoordinate,
)
from netbox_topology_views.tables import CoordinateGroupListTable, CoordinateListTable, CircuitCoordinateListTable, PowerPanelCoordinateListTable, PowerFeedCoordinateListTable
from netbox_topology_views.utils import (
CONF_IMAGE_DIR,
find_image_url,
@@ -62,12 +76,12 @@ from netbox_topology_views.utils import (
IMAGE_FILETYPES
)
from netbox_topology_views.choices import NodeLabelItems
def get_image_for_entity(entity: Union[Device, Circuit, PowerPanel, PowerFeed]):
is_device = isinstance(entity, Device)
query = (
{"object_id": entity.device_role_id}
{"object_id": entity.role_id}
if is_device
else {"content_type_id": ContentType.objects.get_for_model(entity).pk}
)
@@ -76,18 +90,22 @@ def get_image_for_entity(entity: Union[Device, Circuit, PowerPanel, PowerFeed]):
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__)
entity.role.slug if is_device else get_model_slug(entity.__class__)
)
def create_node(
device: Union[Device, Circuit, PowerPanel, PowerFeed], save_coords: bool, group_id="default"
device: Union[Device, Circuit, PowerPanel, PowerFeed],
save_coords: bool,
node_label_items: list,
group_id="default"
):
node = {}
node_content = ""
if isinstance(device, Circuit):
dev_name = device.cid
node["id"] = f"c{device.pk}"
model_name = 'CircuitCoordinate'
if device.provider is not None:
node_content += (
@@ -98,6 +116,7 @@ def create_node(
elif isinstance(device, PowerPanel):
dev_name = device.name
node["id"] = f"p{device.pk}"
model_name = 'PowerPanelCoordinate'
if device.site is not None:
node_content += f"<tr><th>Site: </th><td>{device.site.name}</td></tr>"
@@ -108,6 +127,7 @@ def create_node(
elif isinstance(device, PowerFeed):
dev_name = device.name
node["id"] = f"f{device.pk}"
model_name = 'PowerFeedCoordinate'
if device.power_panel is not None:
node_content += (
@@ -124,17 +144,18 @@ def create_node(
if device.voltage is not None:
node_content += f"<tr><th>Voltage: </th><td>{device.voltage}</td></tr>"
else:
model_name = 'Coordinate'
dev_name = device.name
if dev_name is None:
dev_name = device.device_type.get_full_name
dev_name = device.device_type.full_name
if device.device_type is not None:
node_content += (
f"<tr><th>Type: </th><td>{device.device_type.model}</td></tr>"
)
if device.device_role.name is not None:
if device.role.name is not None:
node_content += (
f"<tr><th>Role: </th><td>{device.device_role.name}</td></tr>"
f"<tr><th>Role: </th><td>{device.role.name}</td></tr>"
)
if device.serial != "":
node_content += f"<tr><th>Serial: </th><td>{device.serial}</td></tr>"
@@ -160,11 +181,26 @@ def create_node(
node["id"] = device.pk
if device.device_role.color != "":
node["color.border"] = "#" + device.device_role.color
if device.site is not None:
node["site"] = device.site.name
node["site_id"] = device.site_id
if device.location is not None:
node["location"] = device.location.name
node["location_id"] = device.location_id
if device.rack is not None:
node["rack"] = device.rack.name
node["rack_id"] = device.rack_id
if device.virtual_chassis is not None:
node["virtual_chassis"] = device.virtual_chassis.name
node["virtual_chassis_id"] = device.virtual_chassis_id
if device.role.color != "":
node["color.border"] = "#" + device.role.color
model_class = getattr(netbox_topology_views.models, model_name)
if group_id is None or group_id == "default":
group_id = Coordinate.get_or_create_default_group(group_id)
group_id = model_class.get_or_create_default_group(group_id)
if not group_id:
print('Exception occured while handling default group.')
return node
@@ -176,10 +212,10 @@ def create_node(
# will not be placed correctly by vis-network.
node["x"] = 0
node["y"] = 0
if Coordinate.objects.filter(group=group, device=device.pk).values('x') and Coordinate.objects.filter(group=group, device=device.pk).values('y'):
if model_class.objects.filter(group=group, device=device.pk).values('x') and model_class.objects.filter(group=group, device=device.pk).values('y'):
# Coordinates data for the device exists in Coordinates Group. Let's assign them
node["x"] = Coordinate.objects.get(group=group, device=device.pk).x
node["y"] = Coordinate.objects.get(group=group, device=device.pk).y
node["x"] = model_class.objects.get(group=group, device=device.pk).x
node["y"] = model_class.objects.get(group=group, device=device.pk).y
node["physics"] = False
elif "coordinates" in device.custom_field_data:
# We prefer the new Coordinate model but leave the deprecated method
@@ -194,10 +230,36 @@ def create_node(
dev_title = "<table><tbody> %s</tbody></table>" % (node_content)
node["title"] = dev_title
node["name"] = dev_name
node["label"] = dev_name
# Create a list of possible label items. Omit None types
label_mapping = {
NodeLabelItems.DEVICE_NAME: dev_name,
NodeLabelItems.DEVICE_TYPE: getattr(device, 'device_type', None),
NodeLabelItems.ROLE: getattr(device, 'role', None),
NodeLabelItems.DESCRIPTION: getattr(device, 'description', None),
NodeLabelItems.PRIMARY_IPV4: getattr(device, 'primary_ip4', None),
NodeLabelItems.PRIMARY_IPV6: getattr(device, 'primary_ip6', None),
NodeLabelItems.OUT_OF_BAND_IP: getattr(device, 'oob_ip', None),
NodeLabelItems.PLATFORM: getattr(device, 'platform', None),
NodeLabelItems.SERIAL: getattr(device, 'serial', None),
NodeLabelItems.TENANT: getattr(device, 'tenant', None),
NodeLabelItems.SITE: getattr(device, 'site', None),
NodeLabelItems.LOCATION: getattr(device, 'location', None),
NodeLabelItems.RACK: getattr(device, 'rack', None),
NodeLabelItems.VIRTUAL_CHASSIS: getattr(device, 'virtual_chassis', None),
}
label_items = []
for item in node_label_items:
if label_mapping[item] and label_mapping[item] is not None:
label_items.append(str(label_mapping[item]))
node_label = '\n'.join(label_items)
node["label"] = node_label
node["shape"] = "image"
node["href"] = device.get_absolute_url()
node["image"] = get_image_for_entity(device)
node["label_num_lines"] = len(label_items)
return node
@@ -206,6 +268,7 @@ def create_edge(
edge_id: int,
termination_a: Dict,
termination_b: Dict,
straight_cables: bool,
circuit: Optional[Dict] = None,
cable: Optional[Cable] = None,
wireless: Optional[Dict] = None,
@@ -258,16 +321,24 @@ def create_edge(
edge["dashes"] = LinePattern().logical
edge["color"] = '#f1c232'
edge["href"] = interface.get_absolute_url() + "trace"
if cable is not None and hasattr(cable, "label") and cable.label:
cable_label = "<br>Label: " + cable.label
else:
cable_label = ""
edge[
"title"
] = f"{title} between<br>{cable_a_dev_name} [{cable_a_name}]<br>{cable_b_dev_name} [{cable_b_name}]"
] = f"{title} between<br>{cable_a_dev_name} [{cable_a_name}]<br>{cable_b_dev_name} [{cable_b_name}] {cable_label}"
if cable is not None:
edge["href"] = cable.get_absolute_url()
if hasattr(cable, 'color') and cable.color != "":
edge["color"] = "#" + cable.color
# if straight_cables == True: edge["smooth"] = False
edge["smooth"] = not straight_cables
return edge
@@ -295,6 +366,7 @@ def get_topology_data(
queryset: QuerySet,
individualOptions: IndividualOptions,
show_unconnected: bool,
ignore_cable_type: list,
save_coords: bool,
show_cables: bool,
show_circuit: bool,
@@ -303,7 +375,14 @@ def get_topology_data(
show_neighbors: bool,
show_power: bool,
show_wireless: bool,
group_sites: bool,
group_locations: bool,
group_racks: bool,
group_virtualchassis: bool,
group_id,
straight_cables: bool,
grid_size: list,
node_label_items: list,
):
supported_termination_types = []
@@ -316,6 +395,7 @@ def get_topology_data(
nodes_devices = {}
edges = []
nodes = []
options = {}
edge_ids = 0
nodes_circuits: Dict[int, Circuit] = {}
nodes_powerpanel: Dict[int, PowerPanel] = {}
@@ -324,8 +404,6 @@ def get_topology_data(
cable_ids = DefaultDict(dict)
interface_ids = DefaultDict(dict)
ignore_cable_type = individualOptions.ignore_cable_type
device_ids = [d.pk for d in queryset]
site_ids = [d.site_id for d in queryset]
@@ -352,12 +430,13 @@ def get_topology_data(
)
for path_complete_interface in path_complete_interfaces:
for connected_endpoint in path_complete_interface.connected_endpoints:
device_ids.append(connected_endpoint.device.id)
if type(connected_endpoint) != ProviderNetwork:
device_ids.append(connected_endpoint.device.id)
if show_circuit:
circuit_terminations = 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_termination in circuit_terminations:
circuit_termination: CircuitTermination
if (
@@ -371,21 +450,21 @@ def get_topology_data(
termination_a = {}
termination_b = {}
circuit_model = {}
if circuit_termination.cable is not None:
if circuit_termination.cable is not None and bool(circuit_termination.cable.a_terminations) and bool(circuit_termination.cable.b_terminations):
termination_a = create_circuit_termination(
circuit_termination.cable.a_terminations[0]
)
termination_b = create_circuit_termination(
circuit_termination.cable.b_terminations[0]
)
elif circuit_termination.provider_network is not None:
elif circuit_termination.termination is not None:
if (
circuit_termination.provider_network_id
circuit_termination.termination_id
not in nodes_provider_networks
):
nodes_provider_networks[
circuit_termination.provider_network.pk
] = circuit_termination.provider_network
circuit_termination.termination.pk
] = circuit_termination.termination
if bool(termination_a) and bool(termination_b):
circuit_model = {
@@ -399,6 +478,7 @@ def get_topology_data(
circuit=circuit_model,
termination_a=termination_a,
termination_b=termination_b,
straight_cables=straight_cables,
)
)
@@ -425,7 +505,7 @@ def get_topology_data(
] = circuit_termination.circuit
for d in nodes_circuits.values():
nodes.append(create_node(d, save_coords, group_id))
nodes.append(create_node(d, save_coords, node_label_items, group_id))
if show_power:
power_panels_ids = PowerPanel.objects.filter(
@@ -468,6 +548,7 @@ def get_topology_data(
termination_a=termination_a,
termination_b=termination_b,
power=True,
straight_cables=straight_cables,
)
)
@@ -475,10 +556,10 @@ def get_topology_data(
cable_ids[power_feed.cable_id][power_feed.cable_end] = termination_b
for d in nodes_powerfeed.values():
nodes.append(create_node(d, save_coords, group_id))
nodes.append(create_node(d, save_coords, node_label_items ,group_id))
for d in nodes_powerpanel.values():
nodes.append(create_node(d, save_coords, group_id))
nodes.append(create_node(d, save_coords, node_label_items, group_id))
if show_logical_connections:
interfaces = Interface.objects.filter(
@@ -506,7 +587,7 @@ def get_topology_data(
edge_ids += 1
termination_a = { "termination_name": interface.name, "termination_device_name": interface.device.name, "device_id": interface.device.id }
termination_b = { "termination_name": destination.name, "termination_device_name": destination.device.name, "device_id": destination.device.id }
edges.append(create_edge(edge_id=edge_ids, termination_a=termination_a, termination_b=termination_b, interface=interface))
edges.append(create_edge(edge_id=edge_ids, termination_a=termination_a, termination_b=termination_b, interface=interface, straight_cables=straight_cables))
nodes_devices[interface.device.id] = interface.device
nodes_devices[destination.device.id] = destination.device
@@ -516,11 +597,11 @@ def get_topology_data(
).select_related("termination_type")
for link in links:
if link.termination_type.name in ignore_cable_type:
if link.termination_type.model in ignore_cable_type:
continue
# Normal device cables
if link.termination_type.name in supported_termination_types:
if link.termination_type.model in supported_termination_types:
complete_link = False
if link.cable_end == "A":
if link.cable_id not in cable_ids:
@@ -586,6 +667,7 @@ def get_topology_data(
cable=link.cable,
termination_a=termination_a,
termination_b=termination_b,
straight_cables=straight_cables,
)
)
@@ -625,9 +707,23 @@ def get_topology_data(
termination_a=termination_a,
termination_b=termination_b,
wireless=wireless,
straight_cables=straight_cables,
)
)
if group_locations:
options['group_locations'] = 'on'
if group_racks:
options['group_racks'] = 'on'
if group_sites:
options['group_sites'] = 'on'
if group_virtualchassis:
options['group_virtualchassis'] = 'on'
if grid_size:
options['grid_size'] = grid_size
else:
options['grid_size'] = list('0')
for qs_device in queryset:
if qs_device.pk not in nodes_devices and show_unconnected:
nodes_devices[qs_device.pk] = qs_device
@@ -635,11 +731,12 @@ def get_topology_data(
results = {}
for d in nodes_devices.values():
nodes.append(create_node(d, save_coords, group_id))
nodes.append(create_node(d, save_coords, node_label_items, group_id))
results["nodes"] = nodes
results["edges"] = edges
results["group"] = group_id
results["options"] = options
return results
@@ -652,8 +749,8 @@ class TopologyHomeView(PermissionRequiredMixin, View):
def get(self, request):
self.filterset = DeviceFilterSet
self.queryset = Device.objects.all().select_related(
"device_type", "device_role"
self.queryset = Device.objects.restrict(request.user, 'view').select_related(
"device_type", "role"
)
self.queryset = self.filterset(request.GET, self.queryset).qs
self.model = self.queryset.model
@@ -665,17 +762,54 @@ class TopologyHomeView(PermissionRequiredMixin, View):
if request.GET:
save_coords, show_unconnected, show_power, show_circuit, show_logical_connections, show_single_cable_logical_conns, show_cables, show_wireless, show_neighbors = get_query_settings(request)
filter_id, ignore_cable_type, save_coords, show_unconnected, show_power, show_circuit, show_logical_connections, show_single_cable_logical_conns, show_cables, show_wireless, group_sites, group_locations, group_racks, group_virtualchassis, group, show_neighbors, straight_cables, grid_size, node_label_items = get_query_settings(request)
filter_required = True
empty_result = False
# Read options from saved filters as NetBox does not handle custom plugin filters
if "filter_id" in request.GET and request.GET["filter_id"] != '':
try:
saved_filter = SavedFilter.objects.get(pk=filter_id)
saved_filter_params = getattr(saved_filter, 'parameters')
if ignore_cable_type == () and 'ignore_cable_type' in saved_filter_params: ignore_cable_type = saved_filter_params['ignore_cable_type']
if save_coords == False and 'save_coords' in saved_filter_params: save_coords = saved_filter_params['save_coords']
if show_unconnected == False and 'show_unconnected' in saved_filter_params: show_unconnected = saved_filter_params['show_unconnected']
if show_power == False and 'show_power' in saved_filter_params: show_power = saved_filter_params['show_power']
if show_circuit == False and 'show_circuit' in saved_filter_params: show_circuit = saved_filter_params['show_circuit']
if show_logical_connections == False and 'show_logical_connections' in saved_filter_params: show_logical_connections = saved_filter_params['show_logical_connections']
if show_single_cable_logical_conns == False and 'show_single_cable_logical_conns' in saved_filter_params: show_single_cable_logical_conns = saved_filter_params['show_single_cable_logical_conns']
if show_cables == False and 'show_cables' in saved_filter_params: show_cables = saved_filter_params['show_cables']
if show_wireless == False and 'show_wireless' in saved_filter_params: show_wireless = saved_filter_params['show_wireless']
if group_sites == False and 'group_sites' in saved_filter_params: group_sites = saved_filter_params['group_sites']
if group_locations == False and 'group_locations' in saved_filter_params: group_locations = saved_filter_params['group_locations']
if group_racks == False and 'group_racks' in saved_filter_params: group_racks = saved_filter_params['group_racks']
if group_virtualchassis == False and 'group_virtualchassis' in saved_filter_params: group_virtualchassis = saved_filter_params['group_virtualchassis']
if show_neighbors == False and 'show_neighbors' in saved_filter_params: show_neighbors = saved_filter_params['show_neighbors']
if straight_cables == False and 'straight_cables' in saved_filter_params: straight_cables = saved_filter_params['straight_cables']
if grid_size == 0 and 'grid_size' in saved_filter_params: grid_size = saved_filter_params['grid_size']
if node_label_items == () and 'node_label_items' in saved_filter_params: node_label_items = saved_filter_params['node_label_items']
except SavedFilter.DoesNotExist: # filter_id not found
pass
except Exception as inst:
print(type(inst))
if "group" not in request.GET:
group_id = "default"
if 'saved_filter_params' in locals() and "group" in saved_filter_params:
group_id = saved_filter_params['group'][0]
else:
group_id = "default"
else:
group_id = request.GET["group"]
if not "draw_init" in request.GET or "draw_init" in request.GET and request.GET["draw_init"].lower() == "true":
filter_required = False
topo_data = get_topology_data(
queryset=self.queryset,
individualOptions=individualOptions,
ignore_cable_type=ignore_cable_type,
save_coords=save_coords,
show_unconnected=show_unconnected,
show_cables=show_cables,
@@ -685,49 +819,58 @@ class TopologyHomeView(PermissionRequiredMixin, View):
show_circuit=show_circuit,
show_power=show_power,
show_wireless=show_wireless,
group_sites=group_sites,
group_locations=group_locations,
group_racks=group_racks,
group_virtualchassis=group_virtualchassis,
group_id=group_id,
straight_cables=straight_cables,
grid_size=grid_size,
node_label_items=node_label_items,
)
if topo_data is None or not topo_data["nodes"]:
empty_result = True
else:
# No GET-Request in URL. We most likely came here from the navigation menu.
preselected_device_roles = IndividualOptions.objects.get(id=individualOptions.id).preselected_device_roles.all().values_list('id', flat=True)
preselected_tags = IndividualOptions.objects.get(id=individualOptions.id).preselected_tags.all().values_list(Lower('name'), flat=True)
ignore_cable_type = IndividualOptions.objects.get(id=individualOptions.id).ignore_cable_type.translate({ord(i): None for i in '[]\''}).split(', ')
if ignore_cable_type == ['']: ignore_cable_type = []
q = QueryDict(mutable=True)
q.setlist("device_role_id", list(preselected_device_roles))
q.setlist("role_id", list(preselected_device_roles))
q.setlist("tag", list(preselected_tags))
q.setlist("ignore_cable_type", ignore_cable_type)
if individualOptions.save_coords: q['save_coords'] = "True"
if individualOptions.show_unconnected: q['show_unconnected'] = "True"
if individualOptions.show_cables: q['show_cables'] = "True"
if individualOptions.show_logical_connections: q['show_logical_connections'] = "True"
if individualOptions.show_single_cable_logical_conns: q['show_single_cable_logical_conns'] = "True"
if individualOptions.show_neighbors: q['show_neighbors'] = "True"
if individualOptions.show_circuit: q['show_circuit'] = "True"
if individualOptions.show_power: q['show_power'] = "True"
if individualOptions.show_wireless: q['show_wireless'] = "True"
if individualOptions.group_sites: q['group_sites'] = "True"
if individualOptions.group_locations: q['group_locations'] = "True"
if individualOptions.group_racks: q['group_racks'] = "True"
if individualOptions.group_virtualchassis: q['group_virtualchassis'] = "True"
if individualOptions.straight_cables: q['straight_cables'] = "True"
if individualOptions.grid_size: q['grid_size'] = individualOptions.grid_size
node_label_items = IndividualOptions.objects.get(id=individualOptions.id).node_label_items.translate({ord(i): None for i in '[]\''}).split(', ')
if node_label_items == ['']: node_label_items = []
q.setlist("node_label_items", node_label_items)
if individualOptions.save_coords: q['save_coords'] = "on"
if individualOptions.show_unconnected: q['show_unconnected'] = "on"
if individualOptions.show_cables: q['show_cables'] = "on"
if individualOptions.show_logical_connections: q['show_logical_connections'] = "on"
if individualOptions.show_single_cable_logical_conns: q['show_single_cable_logical_conns'] = "on"
if individualOptions.show_neighbors: q['show_neighbors'] = "on"
if individualOptions.show_circuit: q['show_circuit'] = "on"
if individualOptions.show_power: q['show_power'] = "on"
if individualOptions.show_wireless: q['show_wireless'] = "on"
if individualOptions.draw_default_layout:
q['draw_init'] = "true"
q['draw_init'] = "True"
else:
q['draw_init'] = "false"
q['draw_init'] = "False"
query_string = q.urlencode()
return HttpResponseRedirect(f"{request.path}?{query_string}")
if is_htmx(request):
return render(
request,
"netbox_topology_views/htmx_topology.html",
{
"filter_form": DeviceFilterForm(request.GET, label_suffix=""),
"topology_data": json.dumps(topo_data),
"broken_image": find_image_url("role-unknown"),
"epoch": int(time.time()),
"basepath": settings.BASE_PATH,
},
)
return render(
request,
"netbox_topology_views/index.html",
@@ -737,6 +880,8 @@ class TopologyHomeView(PermissionRequiredMixin, View):
"broken_image": find_image_url("role-unknown"),
"model": self.model,
"basepath": settings.BASE_PATH,
"filter_required": filter_required,
"empty_result": empty_result,
},
)
@@ -748,9 +893,9 @@ 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",
"dcim.view_devicerole",
"dcim.add_devicerole",
"dcim.change_devicerole",
)
def get(self, request: HttpRequest):
@@ -799,6 +944,132 @@ class TopologyImagesView(PermissionRequiredMixin, View):
},
)
class CircuitCoordinateView(PermissionRequiredMixin, ObjectView):
permission_required = 'netbox_topology_views.view_coordinate'
queryset = CircuitCoordinate.objects.all()
class CircuitCoordinateAddView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'netbox_topology_views.add_coordinate'
queryset = CircuitCoordinate.objects.all()
form = CircuitCoordinatesForm
template_name = 'netbox_topology_views/circuitcoordinate_add.html'
class CircuitCoordinateBulkImportView(BulkImportView):
queryset = CircuitCoordinate.objects.all()
model_form = CircuitCoordinatesImportForm
class CircuitCoordinateListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'netbox_topology_views.view_coordinate'
queryset = CircuitCoordinate.objects.all()
table = CircuitCoordinateListTable
template_name = 'netbox_topology_views/circuitcoordinate_list.html'
filterset = CircuitCoordinatesFilterSet
filterset_form = CircuitCoordinatesFilterForm
class CircuitCoordinateEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'netbox_topology_views.change_coordinate'
queryset = CircuitCoordinate.objects.all()
form = CircuitCoordinatesForm
template_name = 'netbox_topology_views/circuitcoordinate_edit.html'
class CircuitCoordinateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'netbox_topology_views.delete_coordinate'
queryset = CircuitCoordinate.objects.all()
class CircuitCoordinateChangeLogView(PermissionRequiredMixin, ObjectChangeLogView):
permission_required = 'netbox_topology_views.view_coordinate'
queryset = CircuitCoordinate.objects.all()
class PowerPanelCoordinateView(PermissionRequiredMixin, ObjectView):
permission_required = 'netbox_topology_views.view_coordinate'
queryset = PowerPanelCoordinate.objects.all()
class PowerPanelCoordinateAddView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'netbox_topology_views.add_coordinate'
queryset = PowerPanelCoordinate.objects.all()
form = PowerPanelCoordinatesForm
template_name = 'netbox_topology_views/powerpanelcoordinate_add.html'
class PowerPanelCoordinateBulkImportView(BulkImportView):
queryset = PowerPanelCoordinate.objects.all()
model_form = PowerPanelCoordinatesImportForm
class PowerPanelCoordinateListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'netbox_topology_views.view_coordinate'
queryset = PowerPanelCoordinate.objects.all()
table = PowerPanelCoordinateListTable
template_name = 'netbox_topology_views/powerpanelcoordinate_list.html'
filterset = PowerPanelCoordinatesFilterSet
filterset_form = PowerPanelCoordinatesFilterForm
class PowerPanelCoordinateEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'netbox_topology_views.change_coordinate'
queryset = PowerPanelCoordinate.objects.all()
form = PowerPanelCoordinatesForm
template_name = 'netbox_topology_views/powerpanelcoordinate_edit.html'
class PowerPanelCoordinateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'netbox_topology_views.delete_coordinate'
queryset = PowerPanelCoordinate.objects.all()
class PowerPanelCoordinateChangeLogView(PermissionRequiredMixin, ObjectChangeLogView):
permission_required = 'netbox_topology_views.view_coordinate'
queryset = PowerPanelCoordinate.objects.all()
class PowerFeedCoordinateView(PermissionRequiredMixin, ObjectView):
permission_required = 'netbox_topology_views.view_coordinate'
queryset = PowerFeedCoordinate.objects.all()
class PowerFeedCoordinateAddView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'netbox_topology_views.add_coordinate'
queryset = PowerFeedCoordinate.objects.all()
form = PowerFeedCoordinatesForm
template_name = 'netbox_topology_views/powerfeedcoordinate_add.html'
class PowerFeedCoordinateBulkImportView(BulkImportView):
queryset = PowerFeedCoordinate.objects.all()
model_form = PowerFeedCoordinatesImportForm
class PowerFeedCoordinateListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'netbox_topology_views.view_coordinate'
queryset = PowerFeedCoordinate.objects.all()
table = PowerFeedCoordinateListTable
template_name = 'netbox_topology_views/powerfeedcoordinate_list.html'
filterset = PowerFeedCoordinatesFilterSet
filterset_form = PowerFeedCoordinatesFilterForm
class PowerFeedCoordinateEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'netbox_topology_views.change_coordinate'
queryset = PowerFeedCoordinate.objects.all()
form = PowerFeedCoordinatesForm
template_name = 'netbox_topology_views/powerfeedcoordinate_edit.html'
class PowerFeedCoordinateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'netbox_topology_views.delete_coordinate'
queryset = PowerFeedCoordinate.objects.all()
class PowerFeedCoordinateChangeLogView(PermissionRequiredMixin, ObjectChangeLogView):
permission_required = 'netbox_topology_views.view_coordinate'
queryset = PowerFeedCoordinate.objects.all()
class CoordinateView(PermissionRequiredMixin, ObjectView):
permission_required = 'netbox_topology_views.view_coordinate'
@@ -847,10 +1118,19 @@ class CoordinateGroupView(PermissionRequiredMixin, ObjectView):
queryset = CoordinateGroup.objects.all()
def get_extra_context(self, request, instance):
circuittable = CircuitCoordinateListTable(instance.circuitcoordinate_set.all())
circuittable.configure(request)
powerpaneltable = PowerPanelCoordinateListTable(instance.powerpanelcoordinate_set.all())
powerpaneltable.configure(request)
powerfeedtable = PowerFeedCoordinateListTable(instance.powerfeedcoordinate_set.all())
powerfeedtable.configure(request)
table = CoordinateListTable(instance.coordinate_set.all())
table.configure(request)
return {
'circuitcoordinates_table': circuittable,
'powerpanelcoordinates_table': powerpaneltable,
'powerfeedcoordinates_table': powerfeedtable,
'coordinates_table': table,
}
@@ -925,7 +1205,14 @@ class TopologyIndividualOptionsView(PermissionRequiredMixin, View):
'show_circuit': queryset.show_circuit,
'show_power': queryset.show_power,
'show_wireless': queryset.show_wireless,
'group_sites': queryset.group_sites,
'group_locations': queryset.group_locations,
'group_racks': queryset.group_racks,
'group_virtualchassis': queryset.group_virtualchassis,
'draw_default_layout': queryset.draw_default_layout,
'straight_cables': queryset.straight_cables,
'grid_size': queryset.grid_size,
'node_label_items': tuple(queryset.node_label_items.translate({ord(i): None for i in '[]\''}).split(', ')),
},
)
+1 -1
View File
@@ -7,7 +7,7 @@ long_description = readme.read_text()
setup(
name="netbox-topology-views",
version="3.6.1",
version="4.5.0",
description="An NetBox plugin to create Topology maps",
long_description=long_description,
long_description_content_type="text/markdown",