Compare commits

...
71 Commits
Author SHA1 Message Date
MrBlake 86a8daf45e Merge branch 'develop' of https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views into develop 2026-08-06 14:38:20 +02:00
MrBlake f706db754c fix: respect rack unit numbering direction and starting unit 2026-08-06 14:38:16 +02:00
MrBlake c49270fab0 x 2026-07-28 15:06:14 +02:00
MrBlake 6ff2b60bb4 fix: enforce flex layout for topology device spacing 2026-07-28 15:00:32 +02:00
MrBlake ef1ad4d607 fix: enforce topology spacing and light-mode cable highlights 2026-07-28 14:58:13 +02:00
MrBlake a1d28e922d fix: improve cable count badge contrast 2026-07-28 14:48:33 +02:00
MrBlake d9d2e4dd59 fix: detect NetBox theme for topology cable highlights 2026-07-28 14:47:39 +02:00
MrBlake cff9282a60 style: use green cable highlights in light mode 2026-07-28 14:43:20 +02:00
MrBlake 1f80ae5099 fix: improve topology spacing zoom scrolling and wireless support 2026-07-28 14:42:41 +02:00
MrBlake 4680209d83 feat: add fullscreen topology and wireless links 2026-07-28 14:36:08 +02:00
MrBlake bb0693bef7 fix: sort topology devices without rendering rack elevations 2026-07-28 14:32:34 +02:00
MrBlake 1de5ed0565 feat: align topology nodes with racks and add theme-aware exports 2026-07-28 14:26:28 +02:00
MrBlake 1ff68d7026 fix: export all rendered devices from rack elevations 2026-07-22 13:33:49 +02:00
MrBlake 944652f66c feat: export editable draw.io rack and topology elements 2026-07-22 13:31:05 +02:00
MrBlake 36343c9e23 feat: add transparent rack and topology exports 2026-07-22 13:24:45 +02:00
MrBlake 77b1f46ea3 docs: rewrite README for MRB fork and rack topology features 2026-07-22 09:56:53 +02:00
MrBlake cbdf217f18 feat: add draggable topology with interactive cable tracing 2026-07-22 09:45:56 +02:00
MrBlake d8069edd73 feat: improve rack readability and add cable topology 2026-07-22 09:41:03 +02:00
MrBlake a967f88401 fix: avoid collision with NetBox Rack.units property 2026-07-22 09:35:55 +02:00
MrBlake fccc54e75e feat: add multi-rack elevation view and switch icons
- add rack elevation view for one or multiple racks
- render front and rear device positions by rack unit
- show physical cabling below selected racks
- add site, location, rack, and empty-rack filters
- add network, leaf, spine, and aggregation switch icons
- add “Weitere Ansichten” navigation category
2026-07-22 09:26:36 +02:00
Mario 8aea42c50b v4.5.1 (#721) 2026-03-29 10:51:01 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> f56b8a4aec Bump picomatch from 2.3.1 to 2.3.2 in /netbox_topology_views/static_dev (#719)
Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 09:56:55 +02:00
Mario 3aa2bab141 Add option to show cable labels (#720) 2026-03-29 09:52:10 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6c4106e428 Bump immutable from 5.1.2 to 5.1.5 in /netbox_topology_views/static_dev (#710)
Bumps [immutable](https://github.com/immutable-js/immutable-js) from 5.1.2 to 5.1.5.
- [Release notes](https://github.com/immutable-js/immutable-js/releases)
- [Changelog](https://github.com/immutable-js/immutable-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/immutable-js/immutable-js/compare/v5.1.2...v5.1.5)

---
updated-dependencies:
- dependency-name: immutable
  dependency-version: 5.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 11:07:12 +01:00
Mario 3d97f649d9 Add Asset Tag to node label items (#712) 2026-03-10 10:13:04 +01:00
Mario d10ced8157 Fix preselected tags not recognized 2026-03-10 10:07:04 +01:00
Mario 9d4ef31bdd Closes #701: Option to always show cable/interface labels on edges (#707)
* Added draw termination labels
2026-03-10 09:34:45 +01:00
Mario e20f250425 Revert last Show Wireless change (#703) 2026-01-27 19:24:19 +01:00
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
44 changed files with 3393 additions and 859 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
+183 -163
View File
@@ -1,214 +1,234 @@
# Netbox Topology Views Plugin
# MRB NetBox Topology Views
![Version](https://img.shields.io/pypi/v/netbox-topology-views) ![Downloads](https://img.shields.io/pypi/dm/netbox-topology-views)
Ein angepasster Fork von **NetBox Topology Views** mit erweiterten Rack-Ansichten,
interaktiver Kabelverfolgung und zusätzlichen Netzwerk-Icons.
Create topology views/maps from your devices in NetBox.
The connections are based on the cables you created in NetBox.
Support to filter on name, site, tag and device role.
Options to export to xml (for draw.io/diagrams.net) or png.
Das Plugin erzeugt Topologien aus den in NetBox gepflegten Geräten, Ports und
Kabeln. Neben der klassischen Topologie bietet dieser Fork eine vollständige
Darstellung einzelner oder mehrerer Racks einschließlich Vorderseite,
Rückseite und Verkabelung.
## Preview
## Vorteile dieses Forks
![Topology with light mode](doc/img/topology_light.png)
![Topology with dark mode](doc/img/topology_dark.png)
- Ein oder mehrere Racks gemeinsam anzeigen
- Geräte positionsgenau anhand ihrer Höheneinheiten darstellen
- Vorder- und Rückseite eines Racks getrennt betrachten
- Auf- und absteigende HE-Nummerierung sowie individuelle Start-HE automatisch berücksichtigen
- Rackübergreifende und externe Kabelverbindungen erkennen
- Geräte in der Kabeltopologie frei verschieben
- Geräte in der Kabeltopologie innerhalb jedes Racks nach ihrer Rackposition sortieren
- Kabeltopologie in den Browser-Vollbildmodus schalten
- Kabeltopologie zwischen 50 und 200 Prozent zoomen und im Vollbild scrollen
- Geräte in der Kabeltopologie mit rund 5 mm Abstand darstellen
- P2P-WirelessLinks und Multi-Access-WLANs als violette, gestrichelte Funkstrecken darstellen
- Manuelle Positionen pro Rack-Auswahl im Browser speichern
- Verbindungen eines Geräts beim Hover weiß hervorheben
- Alle verbundenen Ports und Gegenstellen eines Geräts anzeigen
- Schnittstellen eines Kabels beim Hover anzeigen
- Geräte und Kabel direkt aus der Topologie in NetBox öffnen
- Einzelne Racks, alle Racks oder die Kabeltopologie als SVG, PNG und draw.io exportieren
- Exporte grundsätzlich mit transparentem Hintergrund erzeugen
- Farben von Rackansicht und Export an den aktiven Light- oder Dark-Mode anpassen
- Topologie als PNG oder diagrams.net/draw.io-XML exportieren
- Zusätzliche Icons für Network-, Leaf-, Spine- und Aggregation-Switche
- Unterstützung für helle und dunkle NetBox-Themes
## Install
## Änderungen gegenüber dem Original
**_NOTE:_** For docker please see: [Docker install](https://github.com/netbox-community/netbox-docker/wiki/Using-Netbox-Plugins)
Dieser Fork ergänzt insbesondere:
**_NOTE:_** Add `RUN mkdir -p /opt/netbox/netbox/static/netbox_topology_views/img` to the Dockerfile-Plugins file to create the image folder
1. Die neue Menükategorie **Weitere Ansichten**.
2. Eine Rack-Ansicht mit Filtern für Site, Location und mehrere Racks.
3. Positionsgenaue Rack-Elevations für Vorder- und Rückseite.
4. Eine Kabeltabelle mit A- und B-Terminierungen.
5. Eine interaktive, verschiebbare Kabeltopologie unter den Racks.
6. Hover-Hervorhebung und Port-zu-Port-Verfolgung für Geräte und Kabel.
7. Lokal gespeicherte Gerätepositionen und eine Funktion zum Zurücksetzen.
8. Ein erweitertes, skalierbares SVG-Iconset für Switch-Rollen.
9. Transparente SVG-, PNG- und draw.io-Exporte für Rack- und Topologieansichten.
10. Anpassungen für die Verwendung mit NetBox 4.6, darunter die geänderte
`Rack.units`-Property.
The plugin is available as a Python package and can be installed with pip.
## Vorschau
Run `pip install netbox-topology-views` in your virtual env.
Die klassische Topologieansicht stammt aus dem ursprünglichen Plugin:
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:
![Topology in light mode](doc/img/topology_light.png)
![Topology in dark mode](doc/img/topology_dark.png)
```no-highlight
# echo netbox-topology-views >> local_requirements.txt
## Installation aus Gitea
Das Plugin kann direkt aus dem `develop`-Branch installiert werden:
```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
"git+https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views.git@develop"
```
Once installed, the plugin needs to be enabled in your `configuration.py`
Für eine reproduzierbare Produktivinstallation sollte statt `develop` ein
Release-Tag oder ein bestimmter Commit verwendet werden:
```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
"git+https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views.git@v4.5.1-mrb.1"
```
Für automatische Neuinstallationen bei einem NetBox-Upgrade wird derselbe
Eintrag in `/opt/netbox/local_requirements.txt` hinterlegt:
```text
git+https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views.git@develop
```
Bei einem privaten Gitea-Repository muss der NetBox-Server Zugriff über einen
Deploy-Token oder SSH-Key erhalten.
## NetBox-Konfiguration
In `netbox/configuration.py` wird das Python-Paket aktiviert:
```python
# In your configuration.py
PLUGINS = ["netbox_topology_views"]
```
PLUGINS = [
"netbox_topology_views",
]
First run `source /opt/netbox/venv/bin/activate` to enter the Python virtual environment.
Then run
```bash
cd /opt/netbox/netbox
pip3 install netbox-topology-views
python3 manage.py migrate netbox_topology_views
python3 manage.py collectstatic --no-input
```
### Versions
| netbox version | netbox-topology-views version |
| -------------- | ----------------------------- |
| >= 4.0.0 | >= v4.0.0 |
| >= 3.7.0 | >= v3.9.0 |
| >= 3.6.4 | >= v3.8.1 |
| >= 3.6.0 | >= v3.7.X |
| >= 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 |
### Update
Run `pip install netbox-topology-views --upgrade` in your venv.
Run `python3 manage.py migrate netbox_topology_views`
Run `python3 manage.py collectstatic --no-input`
Clear you browser cache.
## Configure
### Individual Options
All individual options can be assigned a default value per user directly in the plugin. The default value can be overridden on the filter page.
![Individual Options](doc/img/topology_individual_options.png)
The remaining options must be configured in the `PLUGINS_CONFIG` section of your `netbox/configuration.py`.
Example:
```
PLUGINS_CONFIG = {
'netbox_topology_views': {
'static_image_directory': 'netbox_topology_views/img',
'allow_coordinates_saving': True,
'always_save_coordinates': True
}
"netbox_topology_views": {
"static_image_directory": "netbox_topology_views/img",
"allow_coordinates_saving": True,
"always_save_coordinates": False,
},
}
```
| Setting | Default value | Description |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| static_image_directory | netbox_topology_views/img | (str or pathlib.Path) Specifies the location that images will be loaded from by default. Must be within `STATIC_ROOT` |
| allow_coordinates_saving | False | (bool) Set to true if you want to enable the ability to save the coordinates. |
| always_save_coordinates | False | (bool) Set if you want to enable the option to save coordinates by default. Setting allow_coordinates_saving to true is mandatory. |
Danach werden Migrationen und statische Dateien verarbeitet:
### Custom field: coordinates
```bash
cd /opt/netbox/netbox
/opt/netbox/venv/bin/python manage.py migrate
/opt/netbox/venv/bin/python manage.py collectstatic --no-input
sudo systemctl restart netbox netbox-rq
```
There is also support for custom fields.
Nach Änderungen an CSS oder Icons kann ein Hard-Reload des Browsers mit
`Strg+F5` erforderlich sein.
>**_Note:_** The custom field "coordinates" is deprecated and will be removed in the future. Please use Coordinate Groups instead.
## Aktualisierung
If you create a custom field "coordinates" for "dcim > device" and "Circuits > circuit" with type "text" and name "coordinates" you will see the same layout every time. It is recommended to set this field to "UI visibility" "Hidden" and let the plugin manage it in the background.
```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
"git+https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views.git@develop"
The coordinates are stored as: "X;Y".
cd /opt/netbox/netbox
/opt/netbox/venv/bin/python manage.py migrate
/opt/netbox/venv/bin/python manage.py collectstatic --no-input
sudo systemctl restart netbox netbox-rq
```
> Please read the "Configure" chapter to set the `allow_coordinates_saving` option to True.
You might also set the `always_save_coordinates` option to True.
`--force-reinstall` ist derzeit sinnvoll, da der Fork intern noch die
Paketversion `4.5.1` verwendet.
### Convert custom field to Coordinate Groups
## Verwendung
Please note that values stored in the custom field "coordinates" are not being converted to Coordinate Groups automatically. A pragmatic way to do this conversion yourself is as follows:
+ Navigate to "Topology" > "Filters".
+ Select "Show Unconnected" and "Show Cables".
+ Leave all other filter settings alone. We want all entries to be displayed!
+ Click "Search" and wait for the results to be displayed
+ Select all nodes. This can be done by holding down the Shift key and dragging a frame around all icons with the left mouse button.
+ Drag the selection a tiny bit to the side. This causes all coordinates for all devices to be stored in the "default" group.
> **_Hint_**: Don't wait too long after clicking an icon in order to drag. If you hold the mouse button for too long before dragging starts, the selection is reset._
+ Storing the values might take some time, depending on the number of devices. Please be patient and check "Coordinates" in order to make sure that everthing has been stored.
+ It is save to delete the custom field now.
### Klassische Topologie
### Custom Images
Unter **Plugins → Topology Views → Topology** steht die ursprüngliche,
filterbare Topologieansicht zur Verfügung. Sie unterstützt unter anderem:
To change image with associated device use the `Images` page - it allows to map a device role with an image found in the NetBox static directory (defined by the plugin config `static_image_directory` which defaults to `netbox_topology_views/img`). You can also upload you own custom images to there - these images will automatically be used for a device (if it does not already have a specified image in the settings) if their name is the device role slug.
- physische Kabel
- logische Verbindungen
- Nachbargeräte
- Circuit-Terminations
- Power Feeds
- Wireless Links
- Coordinate Groups
- PNG- und XML-Export
![Images](doc/img/topology_images.png)
### Rack-Ansicht
## Use
Die erweiterte Ansicht befindet sich unter:
Go to the plugins tab in the navbar and click topology or go to `$NETBOX_URL/plugins/netbox_topology_views/` to view your topologies
**Plugins → Topology Views → Weitere Ansichten → Rack-Ansicht**
Select your options for the topology view:
Über Site, Location und Rack können ein einzelnes Rack oder mehrere Racks
ausgewählt werden. Optional lassen sich auch leere Racks einblenden.
![Filter Options](doc/img/topology_filter_options.png)
In der Elevation werden Geräte entsprechend ihrer Position, Gerätehöhe und
Rack-Seite dargestellt. Unter den Elevations folgen:
<dl>
<dt>Coordinate Group</dt>
<dd>Select Coordinate Group. These groups allow devices to be displayed in different positions depending on the group, thus providing different representations for the same topology. If nothing is selected, the group "default" is set automatically.</dd>
<dt>Save Coordinates</dt>
<dd>Save the coordinates of devices in the topology view.</dd>
<dt>Show Unconnected</dt>
<dd>Show 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'.</dd>
<dt>Show Cables</dt>
<dd>Show connections between interfaces, front / rear ports, etc., that are connected with one or more cables. These connections are displayed as solid lines in the color of the cable.</dd>
<dt>Show Logical Connections</dt>
<dd>Show logical connections between interfaces (referred to as Interface Connections in NetBox) in the topology view. Where the path between
interfaces includes multiple cables (e.g., via patch panels), only the end interface connections are shown, not the
intermediate front / rear port connections, etc. This is similar to what was referred to as 'end-to-end' connections in previous versions. These connections are displayed as yellow dotted lines.</dd>
<dt>Show redundant Cable and Logical Connection</dt>
<dd>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.</dd>
<dt>Show Neighbors</dt>
<dd>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.</dd>
<dt>Show Circuit Terminations</dt>
<dd>Show connections which end at a circuit termination in the topology view. These connections are displayed as blue dashed lines.</dd>
<dt>Show Power Feeds</dt>
<dd>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'.</dd>
<dt>Show Wireless Links</dt>
<dd>Displays wireless connections. These connections are displayed as blue dotted lines.</dd>
</dl>
### Coordinates and Coordinate Groups
1. die interaktive Kabeltopologie und
2. eine tabellarische Übersicht sämtlicher betroffener Kabel.
Netbox Topology Views stores the position of the devices. In order to allow different representations for the topology, Coordinate Groups are supported.
In der Kabeltopologie können Geräte per Drag-and-drop verschoben werden. Beim
Hover über ein Gerät werden dessen Kabel hervorgehoben und alle verbundenen
Ports aufgelistet. Beim Hover über ein Kabel erscheinen beide Terminierungen.
Ein Klick öffnet das jeweilige NetBox-Objekt.
> Please read the "Configure" chapter to set the `allow_coordinates_saving` option to True.
You might also set the `always_save_coordinates` option to True.
Über die Export-Schaltflächen können ein einzelnes Rack, alle aktuell
angezeigten Racks oder die interaktive Kabeltopologie als SVG, PNG oder
draw.io-Datei gespeichert werden. Sämtliche Exportformate werden ohne
Hintergrundfläche erzeugt. Im draw.io-Export bleiben Rack-Rahmen, Geräte und
Beschriftungen als einzelne Elemente bearbeitbar. Topologiekabel werden als
echte Kanten exportiert und folgen den Geräten beim Verschieben in draw.io.
Navigate to "Coordinate Groups" in the menu and create as many groups as you like. You can select a group later in the filter pane in order to show icon positions according to this group (see chapter "Use"). You can also omit creating a group if you don't need this feature. Netbox Topology Views automatically creates a group named "default" for you and stores all coordinates in this group, even if you do not select a group in the filter.
Die Positionen werden im `localStorage` des Browsers gespeichert. Sie gelten
damit nur für den jeweiligen Browser und die konkrete Rack-/Filterauswahl.
By default, the position of the devices are calculated with a physics engine. As soon as a device icon is dragged to another location, its position is saved and excluded from the calculation by the physics engine. All saved coordinates can be viewed and edited under the menu item "Coordinates".
## Geräte-Icons
> **_Note:_** At the time of writing, it is not possible to store the positions of circuit terminations, power panels and power feeds, as these are not devices.
Das Plugin ordnet Icons anhand des Slugs einer Device Role zu. Dieser Fork
liefert zusätzlich folgende SVG-Dateien aus:
### Permissions
| Device-Role-Slug | Icon-Datei |
| --- | --- |
| `network-switch` | `network-switch.svg` |
| `leaf-switch` | `leaf-switch.svg` |
| `spine-switch` | `spine-switch.svg` |
| `aggregation-switch` | `aggregation-switch.svg` |
To view `/plugins/netbox_topology-views/topology` you need the following permissions:
+ dcim | device | can view device
+ dcim | site | can view site
+ extras | tag | can view tag
+ dcim | device role | can view device role
Eigene Bilder können weiterhin über die Seite **Images** zugeordnet werden.
Der Standardpfad lautet `netbox_topology_views/img` innerhalb von
`STATIC_ROOT`.
To save `Coordinates` when moving icons:
+ netbox_topology_views | coordinate | change
## Berechtigungen
To view `/plugins/netbox_topology-views/images`:
+ dcim | site | view
+ dcim | device role | view
+ dcim | device role | add
+ dcim | device role | change
Für die klassische Topologie werden mindestens folgende Rechte benötigt:
To view `/plugins/netbox_topology-views/individualoptions`:
+ netbox_topology_views | individual options | change
- `dcim.view_site`
- `dcim.view_device`
Set `Coordinate Groups` according to your needs:
+ netbox_topology_views | coordinate groups | view/add/change/delete
Für die Rack-Ansicht werden benötigt:
Set `Coordinates` according to your needs:
+ netbox_topology_views | coordinate | view/add/change/delete
- `dcim.view_rack`
- `dcim.view_device`
- `dcim.view_cable`
Set `Power Feed Coordinates` according to your needs:
+ netbox_topology_views | power feed coordinate | view/add/change/delete
Für das Speichern klassischer Topologiekoordinaten werden zusätzlich die
entsprechenden Rechte auf `netbox_topology_views.coordinate` benötigt.
Set `Power Panel Coordinates` according to your needs:
+ netbox_topology_views | power panel coordinate | view/add/change/delete
## Kompatibilität
Set `Circuit Coordinates` according to your needs:
+ netbox_topology_views | circuit coordinate | view/add/change/delete
Die Basis dieses Forks ist NetBox Topology Views `4.5.1`. Die neuen
Rack-Funktionen wurden für den Einsatz mit NetBox `4.6.5` angepasst. Da das
ursprüngliche Release offiziell für NetBox 4.5.x ausgewiesen ist, sollten
Updates auf neuere NetBox-Versionen zunächst in einer Testumgebung geprüft
werden.
## Ursprung und Danksagung
Dieses Projekt ist ein Fork des Open-Source-Plugins
[NetBox Topology Views](https://github.com/mattieserver/netbox-topology-views).
Das ursprüngliche Plugin wurde von **Mattijs Vanhaverbeke** erstellt. Der
größte Teil der klassischen Topologieansicht, Datenmodelle, Filter und
Exportfunktionen basiert auf seiner Arbeit und den Beiträgen des ursprünglichen
Projekts.
Dieser Fork wird unabhängig weiterentwickelt und ist nicht mit dem
ursprünglichen Autor oder dem NetBox-Projekt offiziell verbunden.
## Lizenz
Das Projekt steht wie das Original unter der
[Apache License 2.0](LICENSE). Bestehende Copyright-, Lizenz- und
Urheberhinweise des ursprünglichen Projekts bleiben erhalten.
+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.5
- 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
+3 -3
View File
@@ -4,9 +4,9 @@ from netbox.plugins import PluginConfig
class TopologyViewsConfig(PluginConfig):
name = "netbox_topology_views"
verbose_name = "Topology views"
description = "An plugin to render topology maps"
version = "4.0.0"
author = "Mattijs Vanhaverbeke"
description = "A plugin to render topology maps"
version = "4.5.1"
author = "Mattijs Vanhaverbeke & LKE"
author_email = "author@example.com"
base_url = "netbox_topology_views"
required_settings = []
+4 -1
View File
@@ -50,4 +50,7 @@ class PowerFeedCoordinateSerializer(NetBoxModelSerializer):
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", "group_sites", "group_locations", "group_racks", "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",
"draw_termination_labels", "draw_cable_labels", "grid_size", "node_label_items")
+5
View File
@@ -7,5 +7,10 @@ router = NetBoxRouter()
router.register("save-coords", views.SaveCoordsViewSet)
router.register("images", views.SaveRoleImageViewSet)
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
+52 -9
View File
@@ -1,6 +1,8 @@
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
@@ -15,6 +17,11 @@ from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
from netbox_topology_views.api.serializers import (
RoleImageSerializer,
TopologyDummySerializer,
CoordinateGroupSerializer,
CoordinateSerializer,
CircuitCoordinateSerializer,
PowerPanelCoordinateSerializer,
PowerFeedCoordinateSerializer,
)
import netbox_topology_views.models
from netbox_topology_views.models import RoleImage, IndividualOptions, CoordinateGroup, Coordinate, CircuitCoordinate, PowerPanelCoordinate, PowerFeedCoordinate
@@ -22,7 +29,7 @@ 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()
@@ -89,16 +96,14 @@ 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(
self.queryset = Device.objects.restrict(request.user, 'view').select_related(
"device_type", "role"
)
self.queryset = self.filterset(request.GET, self.queryset).qs
@@ -109,7 +114,7 @@ class ExportTopoToXML(PermissionRequiredMixin, ViewSet):
if request.GET:
filter_id, 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,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, draw_termination_labels, draw_cable_labels, 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"] != '':
@@ -117,7 +122,9 @@ class ExportTopoToXML(PermissionRequiredMixin, ViewSet):
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']
@@ -127,19 +134,29 @@ class ExportTopoToXML(PermissionRequiredMixin, ViewSet):
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 draw_termination_labels == False and 'draw_termination_labels' in saved_filter_params: draw_termination_labels = saved_filter_params['draw_termination_labels']
if draw_cable_labels == False and 'draw_cable_labels' in saved_filter_params: draw_cable_labels = saved_filter_params['draw_cable_labels']
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,
@@ -152,9 +169,15 @@ class ExportTopoToXML(PermissionRequiredMixin, ViewSet):
group_sites=group_sites,
group_locations=group_locations,
group_racks=group_racks,
group_virtualchassis=group_virtualchassis,
group_id=group_id,
straight_cables=straight_cables,
draw_termination_labels=draw_termination_labels,
draw_cable_labels=draw_cable_labels,
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:
@@ -162,7 +185,7 @@ 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 = (
@@ -240,3 +263,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
+36
View File
@@ -0,0 +1,36 @@
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'
ASSET_TAG = 'assettag'
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'),
(ASSET_TAG, 'Asset Tag'),
]
+1 -1
View File
@@ -71,7 +71,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
null_value=None,
)
mac_address = MultiValueMACAddressFilter(
field_name='interfaces__mac_address',
field_name='interfaces__mac_addresses__mac_address',
label="MAC address",
)
serial = MultiValueCharFilter(
+179 -53
View File
@@ -23,6 +23,7 @@ from utilities.forms.fields import (
)
from netbox_topology_views.models import IndividualOptions, CoordinateGroup, Coordinate, CircuitCoordinate, PowerPanelCoordinate, PowerFeedCoordinate
from netbox_topology_views.choices import NodeLabelItems
class DeviceFilterForm(
LocalConfigContextFilterForm,
@@ -34,9 +35,13 @@ class DeviceFilterForm(
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'group', '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', name=_("Options")
'group', 'ignore_cable_type', 'save_coords', 'show_unconnected', 'show_cables', 'show_wireless',
'show_logical_connections', 'show_single_cable_logical_conns', 'show_neighbors',
'group_sites', 'group_locations', 'group_racks', 'group_virtualchassis', 'straight_cables',
'draw_termination_labels', 'draw_cable_labels', 'grid_size', 'node_label_items', name=_("Options")
),
FieldSet(
'show_circuit', 'show_power', name=_("Additional filter-independent types (non-devices)")
),
FieldSet('id', name=_("Device")),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_("Location")),
@@ -225,10 +230,14 @@ class DeviceFilterForm(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
tag = TagFilterField(model)
# options
ignore_cable_type = forms.MultipleChoiceField(
label=_('Ignore Termination Types'),
required=False,
choices=IndividualOptions.CHOICES
)
save_coords = forms.NullBooleanField(
label=_('Save Coordinates'),
required=False,
@@ -240,7 +249,7 @@ class DeviceFilterForm(
)
show_unconnected = forms.NullBooleanField(
label=_('Show Unconnected'),
required=False,
required=False,
initial=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
@@ -255,7 +264,12 @@ class DeviceFilterForm(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
show_wireless = forms.NullBooleanField(
label =_('Show Wireless Links'), required=False, initial=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
show_logical_connections = forms.NullBooleanField(
label =_('Show Logical Connections'), required=False, initial=False,
widget=forms.Select(
@@ -274,24 +288,6 @@ class DeviceFilterForm(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
show_circuit = forms.NullBooleanField(
label=_('Show Circuit Terminations'), required=False, initial=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
show_power = forms.NullBooleanField(
label=_('Show Power Feeds'), required=False, initial=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
show_wireless = forms.NullBooleanField(
label =_('Show Wireless Links'), required=False, initial=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
group_sites = forms.NullBooleanField(
label =_('Group Sites'), required=False, initial=False,
widget=forms.Select(
@@ -310,6 +306,80 @@ class DeviceFilterForm(
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
)
)
draw_termination_labels = forms.BooleanField(
label=_('Termination Labels'), required=False, initial=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
draw_cable_labels = forms.BooleanField(
label=_('Cable Labels'), 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
)
)
class RackElevationFilterForm(forms.Form):
"""Compact selector for the rack elevation view."""
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(), required=False, label=_("Site")
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(), required=False, label=_("Location"),
query_params={"site_id": "$site_id"},
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(), required=False, label=_("Racks"),
query_params={"site_id": "$site_id", "location_id": "$location_id"},
help_text=_("Select one or more racks. If empty, all matching racks are shown."),
)
include_empty = forms.BooleanField(
required=False, initial=False, label=_("Include empty racks")
)
class CoordinateGroupsForm(NetBoxModelForm):
fieldsets = (
@@ -485,23 +555,32 @@ class IndividualOptionsForm(NetBoxModelForm):
fieldsets = (
FieldSet
(
'user_id',
'ignore_cable_type',
'preselected_device_roles',
'preselected_tags',
'save_coords',
'show_unconnected',
'show_cables',
'show_wireless',
'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',
'draw_termination_labels',
'draw_cable_labels',
'grid_size',
'node_label_items',
),
FieldSet
(
'show_circuit',
'show_power',
name=_("Additional filter-independent types (non-devices)")
),
)
@@ -554,6 +633,13 @@ class IndividualOptionsForm(NetBoxModelForm):
'with one or more cables. These connections are displayed as solid '
'lines in the color of the cable.')
)
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.')
)
show_logical_connections = forms.BooleanField(
label =_('Show Logical Connections'),
required=False,
@@ -579,28 +665,6 @@ class IndividualOptionsForm(NetBoxModelForm):
'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.')
)
group_sites = forms.BooleanField(
label =_('Group Sites'),
required=False,
@@ -622,6 +686,13 @@ class IndividualOptionsForm(NetBoxModelForm):
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,
@@ -629,12 +700,67 @@ class IndividualOptionsForm(NetBoxModelForm):
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.')
)
draw_termination_labels = forms.BooleanField(
label=('Termination Labels'),
required=False,
initial=False,
help_text=_('Enable this option if you want to draw a label on each '
'cable end.')
)
draw_cable_labels = forms.BooleanField(
label=('Cable Labels'),
required=False,
initial=False,
help_text=_('Enable this option if you want to draw a cable labels.')
)
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.')
)
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', 'group_sites', 'group_locations', 'group_racks', 'draw_default_layout'
'save_coords', 'show_unconnected', 'show_cables', 'show_wireless',
'show_logical_connections', 'show_single_cable_logical_conns', 'show_neighbors',
'group_sites', 'group_locations', 'group_racks', 'group_virtualchassis',
'draw_default_layout', 'straight_cables', 'draw_termination_labels', 'draw_cable_labels',
'grid_size', 'node_label_items', 'show_circuit', 'show_power'
]
@@ -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),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.9 on 2026-02-19 18:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('netbox_topology_views', '0011_individualoptions_node_label_items'),
]
operations = [
migrations.AddField(
model_name='individualoptions',
name='draw_termination_labels',
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.12 on 2026-03-26 07:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('netbox_topology_views', '0012_individualoptions_draw_termination_labels'),
]
operations = [
migrations.AddField(
model_name='individualoptions',
name='draw_cable_labels',
field=models.BooleanField(default=False),
),
]
+27 -7
View File
@@ -25,6 +25,7 @@ from netbox_topology_views.utils import (
image_static_url,
)
from netbox_topology_views.choices import NodeLabelItems
class RoleImage(ChangeLoggingMixin, ExportTemplatesMixin, EventRulesMixin):
class Meta:
@@ -331,12 +332,12 @@ class PowerFeedCoordinate(NetBoxModel):
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(
@@ -395,10 +396,29 @@ class IndividualOptions(NetBoxModel):
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
)
draw_termination_labels = models.BooleanField(
default=False
)
draw_cable_labels = 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):
+5
View File
@@ -84,6 +84,11 @@ menu = PluginMenu(
PluginMenuItem(link="plugins:netbox_topology_views:home", link_text="Topology", permissions=["dcim.view_site", "dcim.view_device"]),
),
),
('WEITERE ANSICHTEN',
(
PluginMenuItem(link="plugins:netbox_topology_views:rack_elevation", link_text="Rack-Ansicht", permissions=["dcim.view_rack", "dcim.view_device", "dcim.view_cable"]),
),
),
('COORDINATES',
(
PluginMenuItem(link="plugins:netbox_topology_views:coordinategroup_list", link_text="Coordinate Groups", buttons=coordinategroup_buttons, permissions=['netbox_topology_views.view_coordinategroup']),
@@ -1 +1 @@
#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}
#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}.rack-elevation-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(34rem,1fr));gap:1rem}.rack-card{border:1px solid var(--tblr-border-color,#ced4da);border-radius:.4rem;background:var(--tblr-bg-surface,#fff);padding:1rem}.rack-card>header{display:flex;justify-content:space-between;margin-bottom:.75rem}.rack-faces{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}.rack-face-title{text-align:center;font-size:.75rem;text-transform:uppercase;margin-bottom:.25rem}.rack-frame{display:flex;height:calc(var(--rack-units)*14px);min-height:280px;max-height:70vh;border:4px solid #3b4654;background:repeating-linear-gradient(to top,rgba(127,127,127,.14) 0,rgba(127,127,127,.14) 1px,transparent 1px,transparent calc(100%/var(--rack-units)))}.rack-unit-labels{width:2rem;display:flex;flex-direction:column;justify-content:space-around;font-size:.55rem;text-align:center;color:var(--tblr-secondary,#667382)}.rack-device-area{position:relative;flex:1;border-left:1px solid #3b4654}.rack-device{position:absolute;left:3px;right:3px;min-height:2px;overflow:hidden;padding:1px 5px;border:2px solid #1685fc;border-radius:3px;background:#162e45;color:#fff;line-height:1.1;text-decoration:none}.rack-device:hover{filter:brightness(1.25);color:#fff;z-index:2}.rack-device span,.rack-device small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rack-device small{font-size:.6rem;opacity:.8}@media(max-width:700px){.rack-elevation-grid{grid-template-columns:1fr}.rack-faces{grid-template-columns:1fr}}
@@ -0,0 +1,155 @@
.rack-frame {
height: calc(var(--rack-units) * 22px);
max-height: none;
}
.rack-device {
padding: 2px 6px;
font-size: .78rem;
line-height: 1.15;
background: #eef6ff;
color: #132238;
}
.rack-device:hover {
min-height: 2.8rem;
overflow: visible;
z-index: 3;
}
.rack-device:hover span { white-space: normal; }
.rack-frame {
border-color: #718096;
background-color: #fff;
background-image: repeating-linear-gradient(to top, rgba(56, 73, 89, .16) 0, rgba(56, 73, 89, .16) 1px, transparent 1px, transparent calc(100% / var(--rack-units)));
}
.rack-device:hover { color: #132238; }
html[data-bs-theme="dark"] .rack-frame,
body[data-bs-theme="dark"] .rack-frame,
html[data-netbox-color-mode="dark"] .rack-frame {
border-color: #3b4654;
background-color: #212b36;
background-image: repeating-linear-gradient(to top, rgba(200, 215, 230, .14) 0, rgba(200, 215, 230, .14) 1px, transparent 1px, transparent calc(100% / var(--rack-units)));
}
html[data-bs-theme="dark"] .rack-device,
body[data-bs-theme="dark"] .rack-device,
html[data-netbox-color-mode="dark"] .rack-device {
background: #162e45;
color: #fff;
}
.rack-topology {
position: relative;
min-height: 18rem;
overflow: auto;
padding: 1rem;
background: var(--tblr-bg-surface-secondary, rgba(127, 127, 127, .05));
}
.rack-topology-lines {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
}
.rack-topology-columns {
position: relative;
z-index: 2;
display: flex;
align-items: flex-start;
justify-content: space-around;
gap: 5rem;
min-width: max-content;
pointer-events: none;
}
.rack-topology-column {
display: flex;
flex-direction: column;
gap: .75rem;
width: 15rem;
}
.rack-topology-column h4 { text-align: center; font-size: .85rem; }
.rack-topology-device-list { display: flex; flex-direction: column; gap: 40px; }
.rack-topology-node {
display: block;
padding: .55rem .75rem;
border: 2px solid var(--node-color);
border-radius: .35rem;
background: var(--tblr-bg-surface, #fff);
color: inherit;
text-decoration: none;
box-shadow: 0 2px 6px rgba(0, 0, 0, .12);
cursor: grab;
touch-action: none;
user-select: none;
pointer-events: auto;
}
.rack-topology-node.is-dragging { cursor: grabbing; z-index: 5; }
.rack-topology-node:hover { color: inherit; filter: brightness(.95); }
.rack-topology-node strong,
.rack-topology-node small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rack-topology-node small { opacity: .7; }
.rack-topology-edge {
fill: none;
stroke-width: 3;
opacity: .8;
pointer-events: stroke;
cursor: pointer;
transition: opacity .12s, stroke .12s, filter .12s, stroke-width .12s;
}
.rack-topology-edge.is-highlighted {
stroke: var(--topology-highlight-color, #16a34a) !important;
stroke-width: 5;
opacity: 1;
filter: drop-shadow(0 0 5px var(--topology-highlight-color, #16a34a));
}
.rack-topology-edge.is-dimmed { opacity: .12; }
.rack-topology-edge.is-wireless {
stroke-dasharray: 7 7;
stroke-linecap: round;
}
#rack-topology-card:fullscreen {
margin: 0 !important;
border: 0;
border-radius: 0;
background: var(--tblr-bg-surface, #fff);
display: flex;
flex-direction: column;
overflow: hidden;
}
#rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; overflow: hidden; }
#rack-topology-card:fullscreen .rack-topology { height: calc(100vh - 5.5rem); }
.rack-topology-tooltip {
display: none;
position: absolute;
z-index: 10;
width: max-content;
max-width: 28rem;
padding: .65rem .8rem;
border: 1px solid var(--tblr-border-color, #ced4da);
border-radius: .35rem;
background: var(--tblr-bg-surface, #fff);
box-shadow: 0 4px 18px rgba(0, 0, 0, .25);
pointer-events: none;
}
.rack-topology-tooltip.is-visible { display: block; }
.rack-topology-tooltip > strong,
.rack-topology-tooltip > span,
.rack-topology-tooltip li span,
.rack-topology-tooltip li small { display: block; }
.rack-topology-tooltip ul { margin: .4rem 0 0; padding: 0; list-style: none; }
.rack-topology-tooltip li + li {
margin-top: .45rem;
padding-top: .45rem;
border-top: 1px solid var(--tblr-border-color, #ced4da);
}
.rack-topology-tooltip small { opacity: .65; }
.rack-cable-count {
min-width: 2rem;
margin-left: .35rem;
background-color: #52606d !important;
color: #fff !important;
text-align: center;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><circle cx="64" cy="64" r="59" fill="#fff4e5" stroke="#e58a16" stroke-width="6"/><rect x="20" y="48" width="88" height="42" rx="7" fill="#513717"/><g fill="#ffc66f"><path d="M28 58h10v8H28zm14 0h10v8H42zm14 0h10v8H56zm14 0h10v8H70zm14 0h16v8H84zM28 71h10v8H28zm14 0h10v8H42zm14 0h10v8H56zm14 0h10v8H70zm14 0h16v8H84z"/></g><path d="M34 27h25m10 0h25M59 27l10 0M64 27v17" fill="none" stroke="#e58a16" stroke-width="7" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 510 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><circle cx="64" cy="64" r="59" fill="#eafff4" stroke="#20a86b" stroke-width="6"/><rect x="20" y="49" width="88" height="39" rx="7" fill="#174637"/><g fill="#8cf0bf"><path d="M29 59h9v7h-9zm13 0h9v7h-9zm13 0h9v7h-9zm13 0h9v7h-9zm13 0h9v7h-9zm13 0h6v7h-6zM29 70h9v7h-9zm13 0h9v7h-9zm13 0h9v7h-9zm13 0h9v7h-9zm13 0h9v7h-9zm13 0h6v7h-6z"/></g><path d="M64 20c-14 8-22 17-22 27 14 1 23-5 26-19 7 9 12 15 20 18-1-12-9-21-24-26z" fill="#20a86b"/></svg>

After

Width:  |  Height:  |  Size: 508 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><circle cx="64" cy="64" r="59" fill="#e8f4ff" stroke="#1685fc" stroke-width="6"/><rect x="22" y="42" width="84" height="45" rx="7" fill="#183a5a"/><path d="M28 52h72v25H28z" fill="#10283e"/><g fill="#52d273"><circle cx="34" cy="64" r="3"/><circle cx="43" cy="64" r="3"/></g><g fill="#8fc8ff"><path d="M55 55h10v7H55zm14 0h10v7H69zm14 0h10v7H83zM55 67h10v7H55zm14 0h10v7H69zm14 0h10v7H83z"/></g><path d="m35 33 9-8v6h40v-6l9 8-9 8v-6H44v6z" fill="#1685fc"/></svg>

After

Width:  |  Height:  |  Size: 525 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><circle cx="64" cy="64" r="59" fill="#f2ecff" stroke="#7654d6" stroke-width="6"/><rect x="20" y="48" width="88" height="42" rx="7" fill="#302657"/><g fill="#bca9ff"><path d="M28 58h9v8h-9zm13 0h9v8h-9zm13 0h9v8h-9zm13 0h9v8h-9zm13 0h9v8h-9zm13 0h7v8h-7zM28 71h9v8h-9zm13 0h9v8h-9zm13 0h9v8h-9zm13 0h9v8h-9zm13 0h9v8h-9zm13 0h7v8h-7z"/></g><path d="M64 45V20M64 25 44 39M64 25l20 14" fill="none" stroke="#7654d6" stroke-width="7" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 522 B

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"]
}
@@ -26,3 +26,69 @@ html[data-netbox-color-mode=dark] #visgraph {
cursor: pointer;
}
}
.rack-elevation-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(34rem, 1fr));
gap: 1rem;
}
.rack-card {
border: 1px solid var(--tblr-border-color, #ced4da);
border-radius: .4rem;
background: var(--tblr-bg-surface, #fff);
padding: 1rem;
}
.rack-card > header { display: flex; justify-content: space-between; margin-bottom: .75rem; }
.rack-faces { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
.rack-face-title { text-align: center; font-size: .75rem; text-transform: uppercase; margin-bottom: .25rem; }
.rack-frame { display: flex; height: calc(var(--rack-units) * 22px); min-height: 280px; border: 4px solid #3b4654; background: repeating-linear-gradient(to top, rgba(127,127,127,.14) 0, rgba(127,127,127,.14) 1px, transparent 1px, transparent calc(100% / var(--rack-units))); }
.rack-unit-labels { width: 2rem; display: flex; flex-direction: column; justify-content: space-around; font-size: .55rem; text-align: center; color: var(--tblr-secondary, #667382); }
.rack-device-area { position: relative; flex: 1; border-left: 1px solid #3b4654; }
.rack-device { position: absolute; left: 3px; right: 3px; min-height: 2px; overflow: hidden; padding: 2px 6px; border: 2px solid #1685fc; border-radius: 3px; background: #eef6ff; color: #132238; font-size: .78rem; line-height: 1.15; text-decoration: none; }
.rack-device:hover { min-height: 2.8rem; overflow: visible; filter: brightness(1.25); color: #fff; z-index: 3; }
.rack-device:hover span { white-space: normal; }
.rack-device span, .rack-device small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rack-device small { font-size: .6rem; opacity: .8; }
.rack-topology { position: relative; min-height: 18rem; overflow: auto; padding: 1rem; background: var(--tblr-bg-surface-secondary, rgba(127,127,127,.05)); }
.rack-topology-lines { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1; }
.rack-topology-columns { position: relative; z-index: 2; display: flex; align-items: flex-start; justify-content: space-around; gap: 5rem; min-width: max-content; pointer-events: none; }
.rack-topology-column { display: flex; flex-direction: column; gap: .75rem; width: 15rem; }
.rack-topology-column h4 { text-align: center; font-size: .85rem; }
.rack-topology-node { display: block; padding: .55rem .75rem; border: 2px solid var(--node-color); border-radius: .35rem; background: var(--tblr-bg-surface, #fff); color: inherit; text-decoration: none; box-shadow: 0 2px 6px rgba(0,0,0,.12); cursor: grab; touch-action: none; user-select: none; pointer-events: auto; }
.rack-topology-node.is-dragging { cursor: grabbing; z-index: 5; }
.rack-topology-node:hover { color: inherit; filter: brightness(.95); }
.rack-topology-node strong, .rack-topology-node small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rack-topology-node small { opacity: .7; }
.rack-topology-edge { fill: none; stroke-width: 3; opacity: .8; pointer-events: stroke; cursor: pointer; transition: opacity .12s, stroke .12s, filter .12s, stroke-width .12s; }
.rack-topology-edge.is-highlighted { stroke: var(--topology-highlight-color, #16a34a) !important; stroke-width: 5; opacity: 1; filter: drop-shadow(0 0 5px var(--topology-highlight-color, #16a34a)); }
.rack-topology-edge.is-dimmed { opacity: .12; }
.rack-topology-tooltip { display: none; position: absolute; z-index: 10; width: max-content; max-width: 28rem; padding: .65rem .8rem; border: 1px solid var(--tblr-border-color, #ced4da); border-radius: .35rem; background: var(--tblr-bg-surface, #fff); box-shadow: 0 4px 18px rgba(0,0,0,.25); pointer-events: none; }
.rack-topology-tooltip.is-visible { display: block; }
.rack-topology-tooltip > strong, .rack-topology-tooltip > span, .rack-topology-tooltip li span, .rack-topology-tooltip li small { display: block; }
.rack-topology-tooltip ul { margin: .4rem 0 0; padding: 0; list-style: none; }
.rack-topology-tooltip li + li { margin-top: .45rem; padding-top: .45rem; border-top: 1px solid var(--tblr-border-color, #ced4da); }
.rack-topology-tooltip small { opacity: .65; }
.rack-cable-count { min-width: 2rem; margin-left: .35rem; background-color: #52606d !important; color: #fff !important; text-align: center; }
.rack-frame {
border-color: #718096;
background-color: #fff;
}
.rack-topology-device-list { display: flex; flex-direction: column; gap: 40px; }
.rack-topology-edge.is-wireless { stroke-dasharray: 7 7; stroke-linecap: round; }
#rack-topology-card:fullscreen { margin: 0 !important; border: 0; border-radius: 0; background: var(--tblr-bg-surface, #fff); display: flex; flex-direction: column; overflow: hidden; }
#rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; overflow: hidden; }
#rack-topology-card:fullscreen .rack-topology { height: calc(100vh - 5.5rem); }
html[data-bs-theme="dark"], body[data-bs-theme="dark"], html[data-netbox-color-mode="dark"] {
.rack-frame { border-color: #3b4654; background-color: #212b36; }
.rack-device { background: #162e45; color: #fff; }
}
@media (max-width: 700px) {
.rack-elevation-grid { grid-template-columns: 1fr; }
.rack-faces { grid-template-columns: 1fr; }
}
@@ -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
}
+230 -24
View File
@@ -1,6 +1,43 @@
import { DataSet } from 'vis-data/esnext'
import { Network } from 'vis-network/esnext'
import { getCookie } from './csrftoken.js'
function generatePortSVG(text) {
// pre-calculate text width
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
const fontFamily = 'helvetica';
const fontSize = 14;
textEl.textContent = text;
textEl.setAttribute('font-family', fontFamily);
textEl.setAttribute('font-size', fontSize + 'px');
svg.appendChild(textEl);
document.body.appendChild(svg);
const textWidth = textEl.getComputedTextLength();
textEl.remove();
svg.remove();
const rectPadding = 2;
const rectBorderWidth = 2;
const rectX = rectBorderWidth / 2;
const rectY = rectBorderWidth / 2;
const rectWidth = textWidth + rectBorderWidth + rectPadding * 2;
const rectHeight = fontSize + rectBorderWidth + rectPadding * 2;
const svgHeight = rectHeight + rectY + rectBorderWidth / 2;
const svgWidth = rectWidth + rectX + rectBorderWidth / 2;
const textX = svgWidth / 2 ;
const textY = svgHeight / 2;
// generate SVG
return 'data:image/svg+xml;charset=utf-8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="' + (svgWidth) + '" height="' + (svgHeight) + '">' +
'<rect x="' + rectX + '" y="' + rectY + '" rx="4" ry="4" width="' + (rectWidth) + '" height="' + (rectHeight) + '" fill="#ffdf3f" stroke="#ffc93f" stroke-width="' + (rectBorderWidth) + '" style="opacity:0.5"/>' +
'<text x="' + textX + '" y="' + textY + '" font-family="' + fontFamily + '" font-size="' + (fontSize) + '" fill="black" text-anchor="middle" dominant-baseline="middle">' + text + '</text>' +
'</svg>'
)
}
const options = {
interaction: {
@@ -36,9 +73,6 @@ const options = {
}
}
// Load CSRF token
const csrftoken = getCookie('csrftoken')
// Render vis graph
let graph = null // vis graph instance
@@ -66,18 +100,152 @@ const coordSaveCheckbox = document.querySelector('#id_save_coords')
const edges = new DataSet(
topologyData.edges.map((node) => ({
...node,
title: htmlTitle(node.title)
title: htmlTitle(node.title),
...(node.drawTerminationLabel && {
arrows: {
from: {
enabled: true,
type: "image",
src: generatePortSVG(node.cable_a_name)
},
to: {
enabled: true,
type: "image",
src: generatePortSVG(node.cable_b_name)
}
}
})
}))
)
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) => {
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(
@@ -108,7 +276,7 @@ const coordSaveCheckbox = document.querySelector('#id_save_coords')
{
method: 'PATCH',
headers: {
'X-CSRFToken': csrftoken,
'X-CSRFToken': window.CSRF_TOKEN,
Accept: 'application/json',
'Content-Type': 'application/json'
},
@@ -138,21 +306,30 @@ 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 - 1) && canvascontext.pointer.canvas.x < (key.x2 + key.border / 2 + 1)
&& canvascontext.pointer.canvas.y > (key.y1 - key.border / 2 - 1) && canvascontext.pointer.canvas.y < (key.y2 + key.border / 2 + 1)) {
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 + 1) || canvascontext.pointer.canvas.x > (key.x2 - key.border / 2 - 1)
|| canvascontext.pointer.canvas.y < (key.y1 + key.border / 2 + 1) || canvascontext.pointer.canvas.y > (key.y2 - key.border / 2 - 1)) {
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") {
@@ -182,20 +359,29 @@ const coordSaveCheckbox = document.querySelector('#id_save_coords')
});
});
}
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).
// 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]]);
nodesArray.push([value.id, value[typeId], value[type], value.label_num_lines]);
}
}
// Split single array above into arrays grouped by node id
@@ -238,7 +424,7 @@ const coordSaveCheckbox = document.querySelector('#id_save_coords')
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/rack)
/* 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)
@@ -252,16 +438,24 @@ const coordSaveCheckbox = document.querySelector('#id_save_coords')
const rectangles = [];
const xValues = [];
const yValues = [];
const yValuesWithLabel = [];
const labelLineHeight = 16;
for(let val of value[1]) {
// 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 rectX = Math.min(...xValues) - rectParams.paddingX;
const rectY = Math.min(...yValues) - rectParams.paddingY;
const rectSizeX = Math.max(...xValues) - Math.min(...xValues) + 2*rectParams.paddingX;
const rectSizeY = Math.max(...yValues) - Math.min(...yValues) + 2*rectParams.paddingY;
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,
@@ -303,8 +497,8 @@ const coordSaveCheckbox = document.querySelector('#id_save_coords')
color: "#337ab7",
paddingX: 77,
paddingY: 77,
textPaddingX: 12,
textPaddingY: 22,
textPaddingX: 22,
textPaddingY: 29,
font: "14px helvetica",
category: "Location"
}
@@ -315,11 +509,23 @@ const coordSaveCheckbox = document.querySelector('#id_save_coords')
color: "green",
paddingX: 70,
paddingY: 70,
textPaddingX: 8,
textPaddingY: 30,
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
@@ -361,12 +567,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 {
@@ -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,7 +1,7 @@
{
"private": true,
"name": "netbox_topology_views",
"version": "4.0.0",
"version": "4.5.1",
"scripts": {
"bundle": "node bundle.js",
"bundle:styles": "node bundle.js --styles",
@@ -15,8 +15,8 @@
"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"
-20
View File
@@ -1,20 +0,0 @@
from netbox.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]
@@ -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-info" href="#">
<i class="mdi mdi-download"></i>
Download XML
</a>
<a id="btnDownloadImage" class="btn btn-info" href="#">
<i class="mdi mdi-download"></i>
Download PNG
</a>
<a class="btn 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>
@@ -61,6 +61,13 @@
<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>
@@ -0,0 +1,617 @@
{% extends 'generic/_base.html' %}
{% load helpers %}
{% load static %}
{% block title %}Rack-Ansicht{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static 'netbox_topology_views/css/app.css' %}">
<link rel="stylesheet" href="{% static 'netbox_topology_views/css/rack_elevation.css' %}">
{% endblock %}
{% block content %}
<div class="card mb-3">
<div class="card-header"><strong>Racks auswählen</strong></div>
<div class="card-body">
<form method="get">
<div class="row g-3">
{% for field in filter_form %}
<div class="col-md-3">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<div class="form-hint">{{ field.help_text }}</div>{% endif %}
{% for error in field.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
</div>
{% endfor %}
</div>
<div class="mt-3">
<button class="btn btn-primary" type="submit"><i class="mdi mdi-magnify"></i> Anzeigen</button>
<a class="btn btn-outline-secondary" href="{% url 'plugins:netbox_topology_views:rack_elevation' %}">Zurücksetzen</a>
</div>
</form>
</div>
</div>
{% if racks %}
<div class="d-flex justify-content-end align-items-center gap-2 mb-3 flex-wrap">
<strong class="me-2">Alle Racks exportieren:</strong>
<button class="btn btn-sm btn-outline-primary rack-export" data-format="svg" data-scope="all">SVG</button>
<button class="btn btn-sm btn-outline-primary rack-export" data-format="png" data-scope="all">PNG</button>
<button class="btn btn-sm btn-outline-primary rack-export" data-format="drawio" data-scope="all">draw.io</button>
</div>
<div class="rack-elevation-grid">
{% for rack in racks %}
<section class="rack-card" data-rack-id="{{ rack.pk }}" data-rack-units="{{ rack.u_height }}">
<header>
<div><a href="{{ rack.get_absolute_url }}"><strong>{{ rack.name }}</strong></a><br>
<small>{{ rack.site }}{% if rack.location %} · {{ rack.location }}{% endif %} · {{ rack.u_height }}U</small></div>
<div class="btn-group" aria-label="{{ rack.name }} exportieren">
<button class="btn btn-sm btn-outline-secondary rack-export" data-format="svg" data-scope="{{ rack.pk }}">SVG</button>
<button class="btn btn-sm btn-outline-secondary rack-export" data-format="png" data-scope="{{ rack.pk }}">PNG</button>
<button class="btn btn-sm btn-outline-secondary rack-export" data-format="drawio" data-scope="{{ rack.pk }}">draw.io</button>
</div>
</header>
<div class="rack-faces">
{% for face, face_label in faces %}
<div class="rack-face">
<div class="rack-face-title">{{ face_label }}</div>
<div class="rack-frame" style="--rack-units: {{ rack.u_height }}">
<div class="rack-unit-labels">
{% for unit in rack.elevation_unit_labels %}<span>{{ unit }}</span>{% endfor %}
</div>
<div class="rack-device-area">
{% for device in rack.elevation_devices %}
{% if device.elevation_face == face %}
<a class="rack-device" href="{{ device.get_absolute_url }}"
data-face="{{ face }}" data-device-name="{{ device.name|default:device.device_type.model }}"
data-device-type="{{ device.device_type }}" data-position="{{ device.position|default:1 }}"
data-device-height="{{ device.device_type.u_height|default:1 }}" data-device-color="#{{ device.role.color|default:'1685fc' }}"
style="bottom: {{ device.elevation_bottom }}%; height: {{ device.elevation_height }}%; border-color: #{{ device.role.color }}"
title="{{ device.name }} · {{ device.device_type }} · U{{ device.position }}">
<span>{{ device.name|default:device.device_type.model }}</span>
<small>U{{ device.position }} · {{ device.device_type }}</small>
</a>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
</div>
<div id="rack-topology-card" class="card mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Kabeltopologie</strong>
<div class="d-flex gap-2">
<div class="btn-group">
<button class="btn btn-sm btn-outline-primary topology-export" data-format="svg">SVG</button>
<button class="btn btn-sm btn-outline-primary topology-export" data-format="png">PNG</button>
<button class="btn btn-sm btn-outline-primary topology-export" data-format="drawio">draw.io</button>
</div>
<div class="btn-group" aria-label="Topologie zoomen">
<button id="rack-topology-zoom-out" class="btn btn-sm btn-outline-secondary" type="button" title="Verkleinern"><i class="mdi mdi-minus"></i></button>
<button id="rack-topology-zoom-reset" class="btn btn-sm btn-outline-secondary" type="button" title="Zoom zurücksetzen"><span id="rack-topology-zoom-label">100%</span></button>
<button id="rack-topology-zoom-in" class="btn btn-sm btn-outline-secondary" type="button" title="Vergrößern"><i class="mdi mdi-plus"></i></button>
</div>
<button id="rack-topology-reset" class="btn btn-sm btn-outline-secondary" type="button">
<i class="mdi mdi-restore"></i> Positionen zurücksetzen
</button>
<button id="rack-topology-fullscreen" class="btn btn-sm btn-outline-secondary" type="button">
<i class="mdi mdi-fullscreen"></i> Vollbild
</button>
</div>
</div>
<div class="card-body">
<div id="rack-topology" class="rack-topology" aria-label="Topologie der ausgewählten Racks">
<svg class="rack-topology-lines" aria-hidden="true"></svg>
<div class="rack-topology-columns"></div>
<div class="rack-topology-tooltip" role="tooltip"></div>
</div>
</div>
</div>
{{ rack_topology|json_script:"rack-topology-data" }}
{{ rack_export|json_script:"rack-export-data" }}
<div class="card mt-4">
<div class="card-header"><strong>Verkabelung der angezeigten Racks</strong> <span class="badge rack-cable-count">{{ cables|length }}</span></div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead><tr><th>Kabel</th><th>A-Seite</th><th>B-Seite</th><th>Typ</th><th>Status</th></tr></thead>
<tbody>
{% for row in cables %}
<tr>
<td><a href="{{ row.cable.get_absolute_url }}">{{ row.cable.label|default:row.cable }}</a></td>
<td>{% if row.a %}<strong>{{ row.a.device_name }}</strong><br><small>{{ row.a.name }}</small>{% else %}—{% endif %}</td>
<td>{% if row.b %}<strong>{{ row.b.device_name }}</strong><br><small>{{ row.b.name }}</small>{% else %}—{% endif %}</td>
<td>{{ row.cable.type|default:'—' }}</td><td>{{ row.cable.get_status_display }}</td>
</tr>
{% empty %}<tr><td colspan="5" class="text-muted text-center py-4">Keine Verkabelung gefunden.</td></tr>{% endfor %}
</tbody>
</table>
</div>
</div>
{% elif has_selection %}
<div class="alert alert-warning">Für diese Auswahl wurden keine belegten Racks gefunden. Aktiviere „Leere Racks einschließen“, um auch leere Racks zu sehen.</div>
{% else %}
<div class="alert alert-info">Wähle ein oder mehrere Racks oder grenze die Ansicht über Standort und Location ein.</div>
{% endif %}
{% endblock %}
{% block javascript %}
<script>
(() => {
const root = document.getElementById('rack-topology');
const source = document.getElementById('rack-topology-data');
if (!root || !source) return;
const data = JSON.parse(source.textContent);
const isWirelessEdge = edge => edge.kind === 'wireless' || edge.kind === 'wireless-lan';
const rackData = JSON.parse(document.getElementById('rack-export-data').textContent);
const columns = root.querySelector('.rack-topology-columns');
const svg = root.querySelector('svg');
const tooltip = root.querySelector('.rack-topology-tooltip');
const resetButton = document.getElementById('rack-topology-reset');
const fullscreenButton = document.getElementById('rack-topology-fullscreen');
const topologyCard = document.getElementById('rack-topology-card');
const zoomInButton = document.getElementById('rack-topology-zoom-in');
const zoomOutButton = document.getElementById('rack-topology-zoom-out');
const zoomResetButton = document.getElementById('rack-topology-zoom-reset');
const zoomLabel = document.getElementById('rack-topology-zoom-label');
let topologyZoom = 1;
const activeNetBoxTheme = () => {
for (const element of [topologyCard, document.body, document.documentElement]) {
const match = getComputedStyle(element).backgroundColor.match(/[\d.]+/g);
if (match && match.length >= 3 && (match.length < 4 || Number(match[3]) > 0)) {
const [red, green, blue] = match.map(Number);
return (.2126 * red + .7152 * green + .0722 * blue) < 140 ? 'dark' : 'light';
}
}
return document.body.dataset.bsTheme || document.documentElement.dataset.bsTheme || 'light';
};
const topologyHighlightColor = () => activeNetBoxTheme() === 'dark' ? '#ffffff' : '#16a34a';
const applyTopologyTheme = () => {
root.style.setProperty('--topology-highlight-color', topologyHighlightColor());
root.querySelectorAll('.rack-topology-edge.is-highlighted').forEach(path => {
path.style.setProperty('stroke', topologyHighlightColor(), 'important');
path.style.setProperty('filter', `drop-shadow(0 0 5px ${topologyHighlightColor()})`, 'important');
});
};
applyTopologyTheme();
const topologyThemeObserver = new MutationObserver(() => requestAnimationFrame(applyTopologyTheme));
topologyThemeObserver.observe(document.body, {attributes: true, attributeFilter: ['data-bs-theme']});
topologyThemeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-bs-theme', 'data-netbox-color-mode'],
});
const storageKey = `netbox-rack-topology-v3:${window.location.pathname}${window.location.search}`;
let storedPositions = {};
try { storedPositions = JSON.parse(localStorage.getItem(storageKey) || '{}'); } catch (_) {}
const groups = new Map();
data.nodes.forEach(node => {
const groupKey = node.rack_id ? `rack-${node.rack_id}` : `external-${node.rack_name || 'Extern'}`;
if (!groups.has(groupKey)) groups.set(groupKey, {
name: node.rack_name || 'Extern',
rackUnits: node.rack_u_height || null,
descUnits: Boolean(node.rack_desc_units),
nodes: [],
});
groups.get(groupKey).nodes.push(node);
});
groups.forEach(group => {
const column = document.createElement('section');
column.className = 'rack-topology-column';
const heading = document.createElement('h4');
heading.textContent = group.name;
column.appendChild(heading);
const nodeContainer = document.createElement('div');
nodeContainer.className = 'rack-topology-device-list';
nodeContainer.style.display = 'flex';
nodeContainer.style.flexDirection = 'column';
nodeContainer.style.gap = '20px';
nodeContainer.style.rowGap = '20px';
column.appendChild(nodeContainer);
group.nodes.sort((a, b) => group.descUnits
? (a.position || 0) - (b.position || 0)
: (b.position || 0) - (a.position || 0)
).forEach(node => {
const item = document.createElement(node.url ? 'a' : 'div');
item.className = 'rack-topology-node';
item.id = `topology-${node.node_id}`;
item.style.setProperty('--node-color', `#${node.color}`);
item.dataset.nodeId = node.node_id;
if (node.url) item.href = node.url;
const nameElement = document.createElement('strong');
nameElement.textContent = node.device_name;
const portElement = document.createElement('small');
portElement.textContent = node.subtitle || node.name;
item.append(nameElement, portElement);
const saved = storedPositions[node.node_id];
if (saved) item.style.transform = `translate(${saved.x}px, ${saved.y}px)`;
nodeContainer.appendChild(item);
});
columns.appendChild(column);
});
const showTooltip = (html, anchor) => {
tooltip.innerHTML = html;
tooltip.classList.add('is-visible');
const rootBounds = root.getBoundingClientRect();
const anchorBounds = anchor.getBoundingClientRect();
tooltip.style.left = `${anchorBounds.right - rootBounds.left + root.scrollLeft + 10}px`;
tooltip.style.top = `${anchorBounds.top - rootBounds.top + root.scrollTop}px`;
};
const hideTooltip = () => tooltip.classList.remove('is-visible');
const escapeHtml = value => {
const element = document.createElement('span');
element.textContent = value == null ? '—' : String(value);
return element.innerHTML;
};
const setEdgeHighlighted = (path, highlighted) => {
path.classList.toggle('is-highlighted', highlighted);
if (highlighted) {
path.style.setProperty('stroke', topologyHighlightColor(), 'important');
path.style.setProperty('filter', `drop-shadow(0 0 5px ${topologyHighlightColor()})`, 'important');
} else {
path.style.removeProperty('stroke');
path.style.removeProperty('filter');
}
};
const drawEdges = () => {
const bounds = root.getBoundingClientRect();
let width = Math.max(root.clientWidth, columns.scrollWidth * topologyZoom + 32);
let height = Math.max(root.clientHeight, columns.scrollHeight * topologyZoom + 32);
svg.style.width = `${width}px`;
svg.style.height = `${height}px`;
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
svg.replaceChildren();
data.edges.forEach(edge => {
const from = document.getElementById(`topology-${edge.from}`);
const to = document.getElementById(`topology-${edge.to}`);
if (!from || !to) return;
const a = from.getBoundingClientRect(), b = to.getBoundingClientRect();
const x1 = a.left + a.width / 2 - bounds.left + root.scrollLeft;
const y1 = a.top + a.height / 2 - bounds.top + root.scrollTop;
const x2 = b.left + b.width / 2 - bounds.left + root.scrollLeft;
const y2 = b.top + b.height / 2 - bounds.top + root.scrollTop;
const bend = Math.max(30, Math.abs(x2 - x1) * .35);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`);
path.setAttribute('stroke', edge.color);
path.classList.add('rack-topology-edge');
if (isWirelessEdge(edge)) path.classList.add('is-wireless');
path.dataset.from = edge.from;
path.dataset.to = edge.to;
const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
title.textContent = edge.label;
path.appendChild(title);
if (edge.url) path.addEventListener('click', () => window.location.href = edge.url);
path.addEventListener('mouseenter', event => {
setEdgeHighlighted(path, true);
showTooltip(
`<strong>${isWirelessEdge(edge) ? 'WLAN · ' : ''}${escapeHtml(edge.label)}</strong>` +
`<span>${escapeHtml(edge.from_device)} · ${escapeHtml(edge.from_port)}</span>` +
`<span>↔ ${escapeHtml(edge.to_device)} · ${escapeHtml(edge.to_port)}</span>`,
event.target
);
});
path.addEventListener('mouseleave', () => {
setEdgeHighlighted(path, false);
hideTooltip();
});
svg.appendChild(path);
});
};
const connectedEdges = nodeId => data.edges.filter(edge => edge.from === nodeId || edge.to === nodeId);
root.querySelectorAll('.rack-topology-node').forEach(node => {
const nodeId = node.dataset.nodeId;
node.addEventListener('mouseenter', () => {
const connections = connectedEdges(nodeId);
svg.querySelectorAll('.rack-topology-edge').forEach(path => {
const connected = path.dataset.from === nodeId || path.dataset.to === nodeId;
setEdgeHighlighted(path, connected);
path.classList.toggle('is-dimmed', !connected);
});
const rows = connections.map(edge => {
const outgoing = edge.from === nodeId;
const localPort = outgoing ? edge.from_port : edge.to_port;
const remoteDevice = outgoing ? edge.to_device : edge.from_device;
const remotePort = outgoing ? edge.to_port : edge.from_port;
return `<li><strong>${escapeHtml(localPort)}</strong><span>→ ${escapeHtml(remoteDevice)} · ${escapeHtml(remotePort)}</span><small>${isWirelessEdge(edge) ? 'WLAN · ' : ''}${escapeHtml(edge.label)}</small></li>`;
}).join('');
showTooltip(`<strong>Verbindungen (${connections.length})</strong><ul>${rows || '<li>Keine Verbindungen</li>'}</ul>`, node);
});
node.addEventListener('mouseleave', () => {
svg.querySelectorAll('.rack-topology-edge').forEach(path => {
setEdgeHighlighted(path, false);
path.classList.remove('is-dimmed');
});
hideTooltip();
});
let drag = null;
node.addEventListener('pointerdown', event => {
if (event.button !== 0) return;
const saved = storedPositions[nodeId] || {x: 0, y: 0};
drag = {startX: event.clientX, startY: event.clientY, x: saved.x, y: saved.y, moved: false};
node.setPointerCapture(event.pointerId);
node.classList.add('is-dragging');
event.preventDefault();
});
node.addEventListener('pointermove', event => {
if (!drag) return;
const x = drag.x + (event.clientX - drag.startX) / topologyZoom;
const y = drag.y + (event.clientY - drag.startY) / topologyZoom;
drag.moved ||= Math.abs(x - drag.x) > 3 || Math.abs(y - drag.y) > 3;
node.style.transform = `translate(${x}px, ${y}px)`;
storedPositions[nodeId] = {x, y};
drawEdges();
});
node.addEventListener('pointerup', event => {
if (!drag) return;
const moved = drag.moved;
drag = null;
node.releasePointerCapture(event.pointerId);
node.classList.remove('is-dragging');
localStorage.setItem(storageKey, JSON.stringify(storedPositions));
if (moved) node.dataset.suppressClick = 'true';
});
node.addEventListener('click', event => {
if (node.dataset.suppressClick === 'true') {
event.preventDefault();
delete node.dataset.suppressClick;
}
});
});
resetButton?.addEventListener('click', () => {
storedPositions = {};
localStorage.removeItem(storageKey);
root.querySelectorAll('.rack-topology-node').forEach(node => node.style.transform = '');
drawEdges();
});
fullscreenButton?.addEventListener('click', async () => {
if (document.fullscreenElement === topologyCard) await document.exitFullscreen();
else await topologyCard.requestFullscreen();
});
document.addEventListener('fullscreenchange', () => {
const active = document.fullscreenElement === topologyCard;
fullscreenButton.innerHTML = active
? '<i class="mdi mdi-fullscreen-exit"></i> Vollbild verlassen'
: '<i class="mdi mdi-fullscreen"></i> Vollbild';
requestAnimationFrame(drawEdges);
});
const setTopologyZoom = value => {
topologyZoom = Math.min(2, Math.max(.5, Math.round(value * 10) / 10));
columns.style.zoom = topologyZoom;
zoomLabel.textContent = `${Math.round(topologyZoom * 100)}%`;
requestAnimationFrame(drawEdges);
};
zoomInButton?.addEventListener('click', () => setTopologyZoom(topologyZoom + .1));
zoomOutButton?.addEventListener('click', () => setTopologyZoom(topologyZoom - .1));
zoomResetButton?.addEventListener('click', () => setTopologyZoom(1));
const xmlEscape = value => String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;'
})[char]);
const safeName = value => String(value || 'export').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, '');
const exportPalette = () => {
const declaredTheme = activeNetBoxTheme();
const dark = declaredTheme === 'dark' || (!declaredTheme && window.matchMedia('(prefers-color-scheme: dark)').matches);
return dark ? {
frame: '#52606d', grid: '#667382', heading: '#8fc8ff', muted: '#a9b7c6',
deviceFill: '#162e45', deviceText: '#ffffff', deviceSubtext: '#cad5df',
} : {
frame: '#718096', grid: '#8b99a8', heading: '#0b69b7', muted: '#52606d',
deviceFill: '#eef6ff', deviceText: '#132238', deviceSubtext: '#52606d',
};
};
const svgDocument = (width, height, content) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${content}</svg>`;
const drawioDocument = cells =>
`<mxfile host="app.diagrams.net" type="device"><diagram name="Rack export"><mxGraphModel page="0" background="none" grid="1" guides="1" connect="1"><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`;
const renderedRackData = racks => racks.map(rack => {
const card = document.querySelector(`.rack-card[data-rack-id="${rack.id}"]`);
if (!card) return rack;
const rackUnits = Number.parseFloat(card.dataset.rackUnits) || rack.u_height || 1;
const devices = Array.from(card.querySelectorAll('.rack-device')).map(device => {
const bottom = Number.parseFloat(device.style.bottom) || 0;
const height = Number.parseFloat(device.style.height) || (100 / rackUnits);
const deviceHeight = Math.max(height / 100 * rackUnits, 1);
const bottomUnits = bottom / 100 * rackUnits;
const startingUnit = Number(rack.starting_unit) || 1;
return {
name: device.dataset.deviceName || device.querySelector('span')?.textContent || 'Gerät',
type: device.dataset.deviceType || '',
position: rack.desc_units
? startingUnit + rackUnits - deviceHeight - bottomUnits
: startingUnit + bottomUnits,
height: deviceHeight,
face: device.dataset.face === 'rear' ? 'rear' : 'front',
color: device.dataset.deviceColor || '#1685fc',
};
});
return {...rack, devices};
});
const buildRackDrawio = racks => {
const palette = exportPalette();
const unit = 22, faceWidth = 250, labelWidth = 28, faceGap = 28, rackGap = 44, header = 55;
const rackWidth = labelWidth + faceWidth * 2 + faceGap;
let cells = '', cellId = 2;
const vertex = (value, style, x, y, width, height) => {
const id = cellId++;
cells += `<mxCell id="${id}" value="${xmlEscape(value)}" style="${style}" vertex="1" parent="1"><mxGeometry x="${x}" y="${y}" width="${width}" height="${height}" as="geometry"/></mxCell>`;
return id;
};
racks.forEach((rack, rackIndex) => {
const baseX = 10 + rackIndex * (rackWidth + rackGap);
const rackHeight = rack.u_height * unit;
const startingUnit = Number(rack.starting_unit) || 1;
vertex(rack.name, `text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;fontSize=16;fontStyle=1;fontColor=${palette.heading};`, baseX, 0, rackWidth, 25);
vertex(`${rack.site}${rack.location ? ` · ${rack.location}` : ''} · ${rack.u_height}U`, `text;html=1;strokeColor=none;fillColor=none;align=left;fontSize=10;fontColor=${palette.muted};`, baseX, 24, rackWidth, 20);
['front', 'rear'].forEach((face, faceIndex) => {
const x = baseX + labelWidth + faceIndex * (faceWidth + faceGap);
vertex(face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE', `text;html=1;strokeColor=none;fillColor=none;align=center;fontSize=10;fontColor=${palette.muted};`, x, 38, faceWidth, 17);
vertex('', `rounded=0;html=1;fillColor=none;strokeColor=${palette.frame};strokeWidth=4;`, x, header, faceWidth, rackHeight);
for (let row = 0; row < rack.u_height; row++) {
const y = header + row * unit;
const unitLabel = rack.desc_units ? startingUnit + row : startingUnit + rack.u_height - 1 - row;
if (faceIndex === 0) vertex(String(unitLabel), `text;html=1;strokeColor=none;fillColor=none;align=right;fontSize=8;fontColor=${palette.muted};`, x - labelWidth, y, labelWidth - 4, unit);
}
rack.devices.filter(device => device.face === face).forEach(device => {
const positionOffset = device.position - startingUnit;
const y = rack.desc_units
? header + positionOffset * unit
: header + rackHeight - (positionOffset + device.height) * unit;
const h = Math.max(device.height * unit, 4);
const value = h >= 31 ? `${device.name}<br><font style="font-size:9px">U${device.position} · ${device.type}</font>` : device.name;
vertex(value, `rounded=1;html=1;whiteSpace=wrap;overflow=hidden;align=left;verticalAlign=top;spacingLeft=5;spacingTop=2;fillColor=${palette.deviceFill};strokeColor=${device.color};strokeWidth=2;fontColor=${palette.deviceText};fontSize=11;`, x + 3, y + 1, faceWidth - 6, h - 2);
});
});
});
return drawioDocument(cells);
};
const buildRackSvg = racks => {
const palette = exportPalette();
const unit = 22, faceWidth = 250, labelWidth = 28, faceGap = 28, rackGap = 44, header = 55;
const rackWidth = labelWidth + faceWidth * 2 + faceGap;
const maxUnits = Math.max(...racks.map(rack => rack.u_height || 1), 1);
const width = racks.length * rackWidth + Math.max(0, racks.length - 1) * rackGap + 20;
const height = header + maxUnits * unit + 25;
let content = '';
racks.forEach((rack, rackIndex) => {
const baseX = 10 + rackIndex * (rackWidth + rackGap);
const rackHeight = rack.u_height * unit;
const startingUnit = Number(rack.starting_unit) || 1;
content += `<text x="${baseX}" y="20" fill="${palette.heading}" font-family="sans-serif" font-size="16" font-weight="700">${xmlEscape(rack.name)}</text>`;
content += `<text x="${baseX}" y="39" fill="${palette.muted}" font-family="sans-serif" font-size="11">${xmlEscape(rack.site)}${rack.location ? ` · ${xmlEscape(rack.location)}` : ''} · ${rack.u_height}U</text>`;
['front', 'rear'].forEach((face, faceIndex) => {
const x = baseX + labelWidth + faceIndex * (faceWidth + faceGap);
content += `<text x="${x + faceWidth / 2}" y="51" text-anchor="middle" fill="${palette.muted}" font-family="sans-serif" font-size="10">${face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE'}</text>`;
content += `<rect x="${x}" y="${header}" width="${faceWidth}" height="${rackHeight}" fill="none" stroke="${palette.frame}" stroke-width="4"/>`;
for (let row = 0; row < rack.u_height; row++) {
const y = header + row * unit;
const unitLabel = rack.desc_units ? startingUnit + row : startingUnit + rack.u_height - 1 - row;
content += `<line x1="${x}" y1="${y}" x2="${x + faceWidth}" y2="${y}" stroke="${palette.grid}" stroke-opacity=".35"/>`;
if (faceIndex === 0) content += `<text x="${x - 6}" y="${y + 15}" text-anchor="end" fill="${palette.muted}" font-family="sans-serif" font-size="9">${unitLabel}</text>`;
}
rack.devices.filter(device => device.face === face).forEach(device => {
const positionOffset = device.position - startingUnit;
const y = rack.desc_units
? header + positionOffset * unit
: header + rackHeight - (positionOffset + device.height) * unit;
const h = Math.max(device.height * unit, 4);
content += `<rect x="${x + 3}" y="${y + 1}" width="${faceWidth - 6}" height="${h - 2}" rx="3" fill="${palette.deviceFill}" stroke="${xmlEscape(device.color)}" stroke-width="2"/>`;
if (h >= 13) content += `<text x="${x + 9}" y="${y + Math.min(15, h - 4)}" fill="${palette.deviceText}" font-family="sans-serif" font-size="11" font-weight="600">${xmlEscape(device.name.slice(0, 34))}</text>`;
if (h >= 31) content += `<text x="${x + 9}" y="${y + 28}" fill="${palette.deviceSubtext}" font-family="sans-serif" font-size="9">U${device.position} · ${xmlEscape(device.type.slice(0, 34))}</text>`;
});
});
});
return {svg: svgDocument(width, height, content), drawio: buildRackDrawio(racks), width, height};
};
const buildTopologySvg = () => {
const palette = exportPalette();
const rootBounds = root.getBoundingClientRect();
let width = Math.max(root.clientWidth, columns.scrollWidth * topologyZoom + 32);
let height = Math.max(root.clientHeight, columns.scrollHeight * topologyZoom + 32);
const positions = new Map();
const nodeCells = new Map();
let content = '';
let drawioCells = '', drawioId = 2;
root.querySelectorAll('.rack-topology-column').forEach(column => {
const heading = column.querySelector('h4');
const headingBounds = heading.getBoundingClientRect();
const headingX = headingBounds.left - rootBounds.left + root.scrollLeft;
const headingY = headingBounds.top - rootBounds.top + root.scrollTop;
content += `<text x="${headingX + headingBounds.width / 2}" y="${headingY + 15}" text-anchor="middle" fill="${palette.heading}" font-family="sans-serif" font-size="13" font-weight="600">${xmlEscape(heading.textContent)}</text>`;
drawioCells += `<mxCell id="group-${drawioId++}" value="${xmlEscape(heading.textContent)}" style="text;html=1;strokeColor=none;fillColor=none;align=center;fontStyle=1;fontColor=${palette.heading};" vertex="1" parent="1"><mxGeometry x="${headingX}" y="${headingY}" width="${headingBounds.width}" height="22" as="geometry"/></mxCell>`;
});
root.querySelectorAll('.rack-topology-node').forEach(node => {
const bounds = node.getBoundingClientRect();
const x = bounds.left - rootBounds.left + root.scrollLeft;
const y = bounds.top - rootBounds.top + root.scrollTop;
width = Math.max(width, x + bounds.width + 20);
height = Math.max(height, y + bounds.height + 20);
positions.set(node.dataset.nodeId, {x: x + bounds.width / 2, y: y + bounds.height / 2});
const color = getComputedStyle(node).getPropertyValue('--node-color').trim() || '#1685fc';
const cellId = `node-${drawioId++}`;
nodeCells.set(node.dataset.nodeId, cellId);
drawioCells += `<mxCell id="${cellId}" value="${xmlEscape(node.querySelector('strong').textContent)}&lt;br&gt;&lt;font style=&quot;font-size:9px&quot;&gt;${xmlEscape(node.querySelector('small').textContent)}&lt;/font&gt;" style="rounded=1;html=1;whiteSpace=wrap;align=left;verticalAlign=middle;spacingLeft=8;fillColor=${palette.deviceFill};strokeColor=${xmlEscape(color)};strokeWidth=2;fontColor=${palette.deviceText};fontSize=12;" vertex="1" parent="1"><mxGeometry x="${x}" y="${y}" width="${bounds.width}" height="${bounds.height}" as="geometry"/></mxCell>`;
content += `<rect x="${x}" y="${y}" width="${bounds.width}" height="${bounds.height}" rx="6" fill="${palette.deviceFill}" stroke="${xmlEscape(color)}" stroke-width="2"/>`;
content += `<text x="${x + 10}" y="${y + 20}" fill="${palette.deviceText}" font-family="sans-serif" font-size="12" font-weight="600">${xmlEscape(node.querySelector('strong').textContent)}</text>`;
content += `<text x="${x + 10}" y="${y + 37}" fill="${palette.deviceSubtext}" font-family="sans-serif" font-size="9">${xmlEscape(node.querySelector('small').textContent)}</text>`;
});
let edgeContent = '';
data.edges.forEach(edge => {
const a = positions.get(edge.from), b = positions.get(edge.to);
if (!a || !b) return;
const bend = Math.max(30, Math.abs(b.x - a.x) * .35);
edgeContent += `<path d="M ${a.x} ${a.y} C ${a.x + bend} ${a.y}, ${b.x - bend} ${b.y}, ${b.x} ${b.y}" fill="none" stroke="${xmlEscape(edge.color)}" stroke-width="3"${isWirelessEdge(edge) ? ' stroke-dasharray="7 7"' : ''}/>`;
const sourceId = nodeCells.get(edge.from), targetId = nodeCells.get(edge.to);
if (sourceId && targetId) {
drawioCells += `<mxCell id="edge-${drawioId++}" value="${xmlEscape(isWirelessEdge(edge) ? `WLAN · ${edge.label}` : edge.label)}" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=${xmlEscape(edge.color)};strokeWidth=2;fontSize=9;${isWirelessEdge(edge) ? 'dashed=1;dashPattern=7 7;' : ''}" edge="1" parent="1" source="${sourceId}" target="${targetId}"><mxGeometry relative="1" as="geometry"/></mxCell>`;
}
});
return {svg: svgDocument(width, height, edgeContent + content), drawio: drawioDocument(drawioCells), width, height};
};
const downloadBlob = (blob, filename) => {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
};
const asDrawio = ({svg, drawio, width, height}) => {
if (drawio) return drawio;
const image = `data:image/svg+xml,${encodeURIComponent(svg)}`;
return `<mxfile host="app.diagrams.net" type="device"><diagram name="Rack export"><mxGraphModel page="0" background="none"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="" style="shape=image;imageAspect=0;aspect=fixed;image=${xmlEscape(image)};" vertex="1" parent="1"><mxGeometry x="0" y="0" width="${width}" height="${height}" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`;
};
const exportGraphic = (graphic, format, filename) => {
if (format === 'svg') {
downloadBlob(new Blob([graphic.svg], {type: 'image/svg+xml'}), `${filename}.svg`);
} else if (format === 'drawio') {
downloadBlob(new Blob([asDrawio(graphic)], {type: 'application/xml'}), `${filename}.drawio`);
} else {
const image = new Image();
const url = URL.createObjectURL(new Blob([graphic.svg], {type: 'image/svg+xml'}));
image.onload = () => {
const scale = 2;
const canvas = document.createElement('canvas');
canvas.width = graphic.width * scale;
canvas.height = graphic.height * scale;
const context = canvas.getContext('2d');
context.clearRect(0, 0, canvas.width, canvas.height);
context.scale(scale, scale);
context.drawImage(image, 0, 0);
canvas.toBlob(blob => downloadBlob(blob, `${filename}.png`), 'image/png');
URL.revokeObjectURL(url);
};
image.src = url;
}
};
document.querySelectorAll('.rack-export').forEach(button => button.addEventListener('click', () => {
const selectedRacks = button.dataset.scope === 'all' ? rackData : rackData.filter(rack => String(rack.id) === button.dataset.scope);
const racks = renderedRackData(selectedRacks);
const name = button.dataset.scope === 'all' ? 'racks-gesamt' : `rack-${safeName(racks[0]?.name)}`;
exportGraphic(buildRackSvg(racks), button.dataset.format, name);
}));
document.querySelectorAll('.topology-export').forEach(button => button.addEventListener('click', () => {
exportGraphic(buildTopologySvg(), button.dataset.format, 'rack-topologie');
}));
requestAnimationFrame(drawEdges);
window.addEventListener('resize', drawEdges);
root.addEventListener('scroll', drawEdges, {passive: true});
})();
</script>
{% endblock %}
@@ -1,4 +0,0 @@
<a role="button" class="btn btn-primary" title="Topology" href="#" hx-get="/plugins/netbox_topology_views/topology/?site_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>
+1
View File
@@ -8,6 +8,7 @@ from . import models, views
urlpatterns = (
path("", RedirectView.as_view(url="topology/", permanent=True)),
path("topology/", views.TopologyHomeView.as_view(), name="home"),
path("rack-elevation/", views.RackElevationView.as_view(), name="rack_elevation"),
path("images/", views.TopologyImagesView.as_view(), name="images"),
path("individualoptions/", views.TopologyIndividualOptionsView.as_view(), name="individualoptions"),
+38 -2
View File
@@ -123,6 +123,10 @@ 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"] == "True":
@@ -173,12 +177,44 @@ def get_query_settings(request):
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"] == "True" :
show_neighbors = True
return filter_id, 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, show_neighbors
straight_cables = False
if "straight_cables" in request.GET:
if request.GET["straight_cables"] == "True":
straight_cables = True
draw_termination_labels = False
if "draw_termination_labels" in request.GET:
if request.GET["draw_termination_labels"] == "True":
draw_termination_labels = True
draw_cable_labels = False
if "draw_cable_labels" in request.GET:
if request.GET["draw_cable_labels"] == "True":
draw_cable_labels = 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, draw_termination_labels, draw_cable_labels, grid_size, node_label_items
class LinePattern():
wireless = [2, 10, 2, 10]
+331 -43
View File
@@ -1,7 +1,6 @@
import json
from functools import reduce
from typing import DefaultDict, Dict, Optional, Union
import time
from itertools import chain
from circuits.models import Circuit, CircuitTermination, ProviderNetwork
@@ -16,6 +15,7 @@ from dcim.models import (
PowerFeed,
PowerPanel,
RearPort,
Rack,
)
from django.conf import settings
from django.contrib import messages
@@ -27,8 +27,7 @@ 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, SavedFilter
from wireless.models import WirelessLink
from utilities.htmx import htmx_partial
from wireless.models import WirelessLAN, WirelessLink
from netbox.views.generic import (
ObjectView,
ObjectListView,
@@ -40,6 +39,7 @@ from netbox.views.generic import (
from netbox_topology_views.filters import DeviceFilterSet, CoordinatesFilterSet, CircuitCoordinatesFilterSet, PowerPanelCoordinatesFilterSet, PowerFeedCoordinatesFilterSet
from netbox_topology_views.forms import (
DeviceFilterForm,
RackElevationFilterForm,
IndividualOptionsForm,
CoordinateGroupsForm,
CircuitCoordinatesForm,
@@ -78,7 +78,7 @@ 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)
@@ -97,7 +97,10 @@ def get_image_for_entity(entity: Union[Device, Circuit, PowerPanel, PowerFeed]):
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 = ""
@@ -146,7 +149,7 @@ def create_node(
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 += (
@@ -189,6 +192,9 @@ def create_node(
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
@@ -226,10 +232,37 @@ 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),
NodeLabelItems.ASSET_TAG: getattr(device, 'asset_tag', 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
@@ -238,6 +271,9 @@ def create_edge(
edge_id: int,
termination_a: Dict,
termination_b: Dict,
straight_cables: bool,
draw_termination_labels: bool,
draw_cable_labels: bool,
circuit: Optional[Dict] = None,
cable: Optional[Cable] = None,
wireless: Optional[Dict] = None,
@@ -290,16 +326,32 @@ 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
if draw_cable_labels is True:
edge["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
if draw_termination_labels is True:
edge["drawTerminationLabel"] = True
edge["cable_a_name"] = cable_a_name
edge["cable_b_name"] = cable_b_name
return edge
@@ -327,6 +379,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,
@@ -338,7 +391,13 @@ def get_topology_data(
group_sites: bool,
group_locations: bool,
group_racks: bool,
group_virtualchassis: bool,
group_id,
straight_cables: bool,
draw_termination_labels: bool,
draw_cable_labels: bool,
grid_size: list,
node_label_items: list,
):
supported_termination_types = []
@@ -360,8 +419,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]
@@ -393,8 +450,8 @@ def get_topology_data(
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 (
@@ -408,21 +465,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 = {
@@ -436,6 +493,9 @@ def get_topology_data(
circuit=circuit_model,
termination_a=termination_a,
termination_b=termination_b,
straight_cables=straight_cables,
draw_termination_labels=draw_termination_labels,
draw_cable_labels=draw_cable_labels,
)
)
@@ -462,7 +522,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(
@@ -505,6 +565,9 @@ def get_topology_data(
termination_a=termination_a,
termination_b=termination_b,
power=True,
straight_cables=straight_cables,
draw_termination_labels=draw_termination_labels,
draw_cable_labels=draw_cable_labels,
)
)
@@ -512,10 +575,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(
@@ -543,7 +606,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, draw_termination_labels=draw_termination_labels, draw_cable_labels=draw_cable_labels))
nodes_devices[interface.device.id] = interface.device
nodes_devices[destination.device.id] = destination.device
@@ -553,11 +616,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:
@@ -623,6 +686,9 @@ def get_topology_data(
cable=link.cable,
termination_a=termination_a,
termination_b=termination_b,
straight_cables=straight_cables,
draw_termination_labels=draw_termination_labels,
draw_cable_labels=draw_cable_labels,
)
)
@@ -662,6 +728,9 @@ def get_topology_data(
termination_a=termination_a,
termination_b=termination_b,
wireless=wireless,
straight_cables=straight_cables,
draw_termination_labels=draw_termination_labels,
draw_cable_labels=draw_cable_labels,
)
)
@@ -671,6 +740,12 @@ def get_topology_data(
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:
@@ -679,7 +754,7 @@ 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
@@ -697,7 +772,7 @@ class TopologyHomeView(PermissionRequiredMixin, View):
def get(self, request):
self.filterset = DeviceFilterSet
self.queryset = Device.objects.all().select_related(
self.queryset = Device.objects.restrict(request.user, 'view').select_related(
"device_type", "role"
)
self.queryset = self.filterset(request.GET, self.queryset).qs
@@ -710,7 +785,10 @@ class TopologyHomeView(PermissionRequiredMixin, View):
if request.GET:
filter_id, 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, 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, draw_termination_labels, draw_cable_labels, 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"] != '':
@@ -718,7 +796,9 @@ class TopologyHomeView(PermissionRequiredMixin, View):
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']
@@ -728,21 +808,33 @@ class TopologyHomeView(PermissionRequiredMixin, View):
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 draw_termination_labels == False and 'draw_termination_labels' in saved_filter_params: draw_termination_labels = saved_filter_params['draw_termination_labels']
if draw_cable_labels == False and 'draw_cable_labels' in saved_filter_params: draw_cable_labels = saved_filter_params['draw_cable_labels']
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,
@@ -755,17 +847,29 @@ class TopologyHomeView(PermissionRequiredMixin, View):
group_sites=group_sites,
group_locations=group_locations,
group_racks=group_racks,
group_virtualchassis=group_virtualchassis,
group_id=group_id,
straight_cables=straight_cables,
draw_termination_labels=draw_termination_labels,
draw_cable_labels=draw_cable_labels,
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)
preselected_tags = IndividualOptions.objects.get(id=individualOptions.id).preselected_tags.all().values_list('slug', 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("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"
@@ -779,6 +883,15 @@ class TopologyHomeView(PermissionRequiredMixin, View):
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.draw_termination_labels: q['draw_termination_labels'] = "True"
if individualOptions.draw_cable_labels: q['draw_cable_labels'] = "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.draw_default_layout:
q['draw_init'] = "True"
else:
@@ -787,20 +900,6 @@ class TopologyHomeView(PermissionRequiredMixin, View):
query_string = q.urlencode()
return HttpResponseRedirect(f"{request.path}?{query_string}")
if htmx_partial(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",
@@ -810,10 +909,193 @@ 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,
},
)
def _rack_endpoint(termination):
"""Return a template-friendly representation of any cable termination."""
device = getattr(termination, "device", None)
return {
"node_id": f"device-{device.pk}" if device else f"external-{termination._meta.model_name}-{termination.pk}",
"device_id": getattr(device, "pk", None),
"device_name": getattr(device, "name", None) or "External",
"name": getattr(termination, "name", str(termination)),
"rack_id": getattr(device, "rack_id", None),
"rack_name": getattr(getattr(device, "rack", None), "name", None) or "Extern",
"url": device.get_absolute_url() if device else None,
"color": getattr(getattr(device, "role", None), "color", None) or "1685fc",
"position": float(device.position or 1) if device else None,
"height": float(max(getattr(getattr(device, "device_type", None), "u_height", 1) or 1, 1)) if device else 1,
"face": str(device.face or "front").lower() if device else "front",
"rack_u_height": getattr(getattr(device, "rack", None), "u_height", None),
"rack_desc_units": getattr(getattr(device, "rack", None), "desc_units", False),
"rack_starting_unit": getattr(getattr(device, "rack", None), "starting_unit", 1),
"subtitle": str(device.device_type) if device else getattr(termination, "name", str(termination)),
}
class RackElevationView(PermissionRequiredMixin, View):
"""Render one or more complete rack elevations and their physical cabling."""
permission_required = ("dcim.view_rack", "dcim.view_device", "dcim.view_cable")
def get(self, request):
racks = Rack.objects.restrict(request.user, "view").select_related("site", "location")
if not request.GET:
racks = racks.none()
selected_racks = request.GET.getlist("rack_id")
if selected_racks:
racks = racks.filter(pk__in=selected_racks)
else:
if request.GET.getlist("site_id"):
racks = racks.filter(site_id__in=request.GET.getlist("site_id"))
if request.GET.getlist("location_id"):
racks = racks.filter(location_id__in=request.GET.getlist("location_id"))
devices = Device.objects.restrict(request.user, "view").filter(
rack_id__in=racks.values("pk")
).select_related("device_type", "role", "rack")
if request.GET.get("include_empty") not in ("on", "True", "true", "1"):
racks = racks.filter(devices__in=devices).distinct()
rack_list = list(racks.order_by("site__name", "location__name", "name"))
rack_ids = {rack.pk for rack in rack_list}
rack_devices = {rack_id: [] for rack_id in rack_ids}
device_ids = []
for device in devices.filter(rack_id__in=rack_ids).order_by("rack_id", "position", "name"):
device_ids.append(device.pk)
height = float(max(getattr(device.device_type, "u_height", 1) or 1, 1))
rack_height = max(device.rack.u_height or 1, 1)
rack_start = float(device.rack.starting_unit or 1)
position_offset = float(device.position or rack_start) - rack_start
if device.rack.desc_units:
bottom_units = rack_height - position_offset - height
else:
bottom_units = position_offset
device.elevation_bottom = f"{max(bottom_units, 0) / rack_height * 100:.4f}"
device.elevation_height = f"{min(height / rack_height * 100, 100):.4f}"
device.elevation_face = str(device.face or "front").lower()
rack_devices[device.rack_id].append(device)
for rack in rack_list:
rack.elevation_devices = rack_devices[rack.pk]
rack_start = rack.starting_unit or 1
if rack.desc_units:
rack.elevation_unit_labels = range(rack_start, rack_start + (rack.u_height or 0))
else:
rack.elevation_unit_labels = range(
rack_start + (rack.u_height or 0) - 1,
rack_start - 1,
-1,
)
rack_export = []
for rack in rack_list:
rack_export.append({
"id": rack.pk, "name": rack.name, "u_height": rack.u_height,
"starting_unit": rack.starting_unit, "desc_units": rack.desc_units,
"site": str(rack.site), "location": str(rack.location or ""),
"devices": [{
"name": device.name or str(device.device_type),
"type": str(device.device_type),
"position": float(device.position or 1),
"height": float(max(device.device_type.u_height or 1, 1)),
"face": device.elevation_face,
"color": f"#{device.role.color}" if device.role.color else "#1685fc",
} for device in rack.elevation_devices],
})
cable_rows = []
topology_nodes = {}
topology_edges = []
if device_ids:
cable_ids = CableTermination.objects.filter(
_device_id__in=device_ids
).values_list("cable_id", flat=True).distinct()
cables = Cable.objects.restrict(request.user, "view").filter(pk__in=cable_ids)
for cable in cables.order_by("label", "pk"):
a_terms, b_terms = list(cable.a_terminations), list(cable.b_terminations)
for index in range(max(len(a_terms), len(b_terms), 1)):
a = _rack_endpoint(a_terms[index]) if index < len(a_terms) else None
b = _rack_endpoint(b_terms[index]) if index < len(b_terms) else None
if (a and a["rack_id"] in rack_ids) or (b and b["rack_id"] in rack_ids):
cable_rows.append({"cable": cable, "a": a, "b": b})
for endpoint in (a, b):
if endpoint:
topology_nodes[endpoint["node_id"]] = endpoint
if a and b:
topology_edges.append({
"from": a["node_id"], "to": b["node_id"],
"from_device": a["device_name"], "to_device": b["device_name"],
"from_port": a["name"], "to_port": b["name"],
"label": cable.label or str(cable),
"color": f"#{cable.color}" if cable.color else "#1685fc",
"url": cable.get_absolute_url(),
"kind": "cable",
})
wireless_links = WirelessLink.objects.restrict(request.user, "view").filter(
Q(interface_a__device_id__in=device_ids) |
Q(interface_b__device_id__in=device_ids)
).select_related(
"interface_a__device__rack", "interface_a__device__role", "interface_a__device__device_type",
"interface_b__device__rack", "interface_b__device__role", "interface_b__device__device_type",
)
for wireless_link in wireless_links:
a = _rack_endpoint(wireless_link.interface_a)
b = _rack_endpoint(wireless_link.interface_b)
topology_nodes[a["node_id"]] = a
topology_nodes[b["node_id"]] = b
topology_edges.append({
"from": a["node_id"], "to": b["node_id"],
"from_device": a["device_name"], "to_device": b["device_name"],
"from_port": a["name"], "to_port": b["name"],
"label": wireless_link.ssid or str(wireless_link),
"color": "#8b5cf6", "url": wireless_link.get_absolute_url(),
"kind": "wireless",
})
wireless_lans = WirelessLAN.objects.restrict(request.user, "view").filter(
interfaces__device_id__in=device_ids
).distinct()
for wireless_lan in wireless_lans:
wlan_node_id = f"wlan-{wireless_lan.pk}"
topology_nodes[wlan_node_id] = {
"node_id": wlan_node_id, "device_id": None,
"device_name": f"WLAN · {wireless_lan.ssid}",
"name": wireless_lan.ssid, "subtitle": "Multi-Access WLAN",
"rack_id": None, "rack_name": "Funknetze",
"url": wireless_lan.get_absolute_url(), "color": "8b5cf6",
"position": None, "height": 1, "face": "front", "rack_u_height": None,
}
wlan_interfaces = Interface.objects.restrict(request.user, "view").filter(
wireless_lans=wireless_lan
).select_related("device__rack", "device__role", "device__device_type")
for interface in wlan_interfaces:
endpoint = _rack_endpoint(interface)
topology_nodes[endpoint["node_id"]] = endpoint
topology_edges.append({
"from": endpoint["node_id"], "to": wlan_node_id,
"from_device": endpoint["device_name"],
"to_device": f"WLAN · {wireless_lan.ssid}",
"from_port": endpoint["name"], "to_port": wireless_lan.ssid,
"label": wireless_lan.ssid, "color": "#8b5cf6",
"url": wireless_lan.get_absolute_url(), "kind": "wireless-lan",
})
return render(request, "netbox_topology_views/rack_elevation.html", {
"filter_form": RackElevationFilterForm(request.GET or None),
"racks": rack_list, "cables": cable_rows,
"rack_topology": {"nodes": list(topology_nodes.values()), "edges": topology_edges},
"rack_export": rack_export,
"faces": (("front", "Vorderseite"), ("rear", "Rückseite")),
"has_selection": bool(request.GET),
})
CONFIG = settings.PLUGINS_CONFIG["netbox_topology_views"]
ADDITIONAL_ROLES = (PowerPanel, PowerFeed, Circuit)
@@ -1136,7 +1418,13 @@ class TopologyIndividualOptionsView(PermissionRequiredMixin, View):
'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,
'draw_termination_labels': queryset.draw_termination_labels,
'draw_cable_labels': queryset.draw_cable_labels,
'grid_size': queryset.grid_size,
'node_label_items': tuple(queryset.node_label_items.translate({ord(i): None for i in '[]\''}).split(', ')),
},
)
+2 -2
View File
@@ -7,8 +7,8 @@ long_description = readme.read_text()
setup(
name="netbox-topology-views",
version="4.0.0",
description="An NetBox plugin to create Topology maps",
version="4.5.1",
description="A NetBox plugin to create Topology maps",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/mattieserver/netbox-topology-views",