Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86a8daf45e | ||
|
|
f706db754c | ||
|
|
c49270fab0 | ||
|
|
6ff2b60bb4 | ||
|
|
ef1ad4d607 | ||
|
|
a1d28e922d | ||
|
|
d9d2e4dd59 | ||
|
|
cff9282a60 | ||
|
|
1f80ae5099 | ||
|
|
4680209d83 | ||
|
|
bb0693bef7 | ||
|
|
1de5ed0565 | ||
|
|
1ff68d7026 | ||
|
|
944652f66c | ||
|
|
36343c9e23 | ||
|
|
77b1f46ea3 | ||
|
|
cbdf217f18 | ||
|
|
d8069edd73 | ||
|
|
a967f88401 | ||
|
|
fccc54e75e |
@@ -1,221 +1,234 @@
|
||||
# Netbox Topology Views Plugin
|
||||
# MRB 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
|
||||
|
||||

|
||||

|
||||
- 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
|
||||
|
||||
First run `source /opt/netbox/venv/bin/activate` to enter the Python virtual environment.
|
||||
Die klassische Topologieansicht stammt aus dem ursprünglichen Plugin:
|
||||
|
||||

|
||||

|
||||
|
||||
## Installation aus Gitea
|
||||
|
||||
Das Plugin kann direkt aus dem `develop`-Branch installiert werden:
|
||||
|
||||
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
|
||||
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
|
||||
"git+https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views.git@develop"
|
||||
```
|
||||
|
||||
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:
|
||||
Für eine reproduzierbare Produktivinstallation sollte statt `develop` ein
|
||||
Release-Tag oder ein bestimmter Commit verwendet werden:
|
||||
|
||||
```no-highlight
|
||||
echo netbox-topology-views >> /opt/netbox/local_requirements.txt
|
||||
```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"
|
||||
```
|
||||
|
||||
Once installed, the plugin needs to be enabled in your `configuration.py`:
|
||||
```no-highlight
|
||||
vim /opt/netbox/netbox/netbox/configuration.py
|
||||
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
|
||||
PLUGINS = ["netbox_topology_views"]
|
||||
```
|
||||
PLUGINS = [
|
||||
"netbox_topology_views",
|
||||
]
|
||||
|
||||
Finally, restart NetBox:
|
||||
```no-highlight
|
||||
systemctl restart netbox
|
||||
```
|
||||
|
||||
### Versions
|
||||
|
||||
> [!NOTE]
|
||||
> If the version of netbox is not listed here the plugin will most likely not work. Remove the plugin if you want to update netbox before this plugin has released an update for that netbox version.
|
||||
|
||||
| netbox version | netbox-topology-views version |
|
||||
| -------------- | ----------------------------- |
|
||||
| = 4.5.X | = v4.5.1 |
|
||||
| = 4.4.X | = v4.4.0 |
|
||||
| = 4.3.X | = v4.3.0 |
|
||||
| = 4.2.1 | = v4.2.1 |
|
||||
| = 4.2.0 | = v4.2.0 |
|
||||
| = 4.1.X | = v4.1.0 |
|
||||
| = 4.0.X | = v4.0.0 |
|
||||
| = 3.7.0 | = v3.9.0 |
|
||||
| = 3.6.4 | = v3.8.1 |
|
||||
| = 3.6.0 | = v3.7.0 |
|
||||
| = 3.5.0 | = v3.6.2 |
|
||||
|
||||
|
||||
### Update
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
### 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.
|
||||
|
||||

|
||||
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.
|
||||
|
||||
@@ -6,7 +6,7 @@ class TopologyViewsConfig(PluginConfig):
|
||||
verbose_name = "Topology views"
|
||||
description = "A plugin to render topology maps"
|
||||
version = "4.5.1"
|
||||
author = "Mattijs Vanhaverbeke"
|
||||
author = "Mattijs Vanhaverbeke & LKE"
|
||||
author_email = "author@example.com"
|
||||
base_url = "netbox_topology_views"
|
||||
required_settings = []
|
||||
|
||||
@@ -230,7 +230,6 @@ class DeviceFilterForm(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
|
||||
tag = TagFilterField(model)
|
||||
|
||||
# options
|
||||
@@ -361,6 +360,27 @@ class DeviceFilterForm(
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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 = (
|
||||
FieldSet('name', 'description', name=_("Group Details")),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 |
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[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)}<br><font style="font-size:9px">${xmlEscape(node.querySelector('small').textContent)}</font>" 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 %}
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from dcim.models import (
|
||||
PowerFeed,
|
||||
PowerPanel,
|
||||
RearPort,
|
||||
Rack,
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
@@ -26,7 +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 wireless.models import WirelessLAN, WirelessLink
|
||||
from netbox.views.generic import (
|
||||
ObjectView,
|
||||
ObjectListView,
|
||||
@@ -38,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,
|
||||
@@ -913,6 +915,187 @@ class TopologyHomeView(PermissionRequiredMixin, View):
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user