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 | ||
|
|
8aea42c50b | ||
|
|
f56b8a4aec | ||
|
|
3aa2bab141 | ||
|
|
6c4106e428 | ||
|
|
3d97f649d9 | ||
|
|
d10ced8157 | ||
|
|
9d4ef31bdd | ||
|
|
e20f250425 | ||
|
|
89c56c8d1c | ||
|
|
619e959a8e | ||
|
|
10f545b509 | ||
|
|
3810e9567f | ||
|
|
5981a21830 | ||
|
|
8100b8146f | ||
|
|
6c047b1022 | ||
|
|
4afa4f721f | ||
|
|
e7df2f6772 | ||
|
|
b85e548049 | ||
|
|
097fe5c685 | ||
|
|
c83479fb1d | ||
|
|
79a234fcc9 | ||
|
|
db76752a82 | ||
|
|
47f263f5c2 | ||
|
|
6eb20004f9 | ||
|
|
836d9f40d7 | ||
|
|
607347ff18 | ||
|
|
0f6051731a | ||
|
|
3c9aebfd2f | ||
|
|
60a5fe1eae | ||
|
|
3fbeb97810 | ||
|
|
f3b822e18d | ||
|
|
555c1fac4c | ||
|
|
cf8641939a | ||
|
|
2893fff111 | ||
|
|
25fdcb6cf4 | ||
|
|
5d330bc830 | ||
|
|
03b8c9c76e | ||
|
|
02a7118a33 | ||
|
|
44e49495fe | ||
|
|
f4b1e34826 | ||
|
|
f8c6e9e193 | ||
|
|
19adcab4a9 | ||
|
|
d2d2106246 | ||
|
|
9072faf7cf | ||
|
|
6f43b959bd | ||
|
|
1048feff52 | ||
|
|
7ff0a9650f | ||
|
|
8d5de5c49f | ||
|
|
96afdf8d83 | ||
|
|
aba0598947 | ||
|
|
142de460b9 | ||
|
|
2600209724 | ||
|
|
ed8a717bc5 | ||
|
|
0b7046b10d | ||
|
|
e784e717ca | ||
|
|
2895294903 | ||
|
|
8368ffa2fd | ||
|
|
23428c8ad2 | ||
|
|
c00b8c2cfe | ||
|
|
32003e59f9 | ||
|
|
bd32642b5c | ||
|
|
f463804087 | ||
|
|
b92b012309 | ||
|
|
74c295837c | ||
|
|
a543f5cb0c | ||
|
|
50c90a4328 | ||
|
|
374a88266a | ||
|
|
60cbc94a62 | ||
|
|
649305c83e | ||
|
|
b3397f4f4a | ||
|
|
a23604261a | ||
|
|
d9571b30b5 | ||
|
|
76b43f5d1e | ||
|
|
becfaaa054 | ||
|
|
bec301eb9d | ||
|
|
f28623b378 | ||
|
|
16837047be | ||
|
|
987c7ef34c | ||
|
|
dbe331afb8 | ||
|
|
86c0c55896 | ||
|
|
78b8a5ae97 | ||
|
|
71e4807cb1 | ||
|
|
dfbe26318f | ||
|
|
debc314714 | ||
|
|
d14d9c1414 | ||
|
|
d48d213b2c | ||
|
|
e5e5a8a0ef | ||
|
|
1b64f40564 | ||
|
|
33a198517b | ||
|
|
daf35c097d | ||
|
|
63b9775218 | ||
|
|
69cd2c431c | ||
|
|
873c757606 | ||
|
|
8f520f9a6a | ||
|
|
0a86d2bbea | ||
|
|
34437f1b5c | ||
|
|
6ee787bfb7 | ||
|
|
8b1d2b56f3 | ||
|
|
73490b6c64 | ||
|
|
164104919b | ||
|
|
d9a86966d4 | ||
|
|
1adb4b2a0a | ||
|
|
252ddd4c50 | ||
|
|
7a60f6838d | ||
|
|
1085389039 | ||
|
|
a1f4478305 | ||
|
|
aca8b31d1f | ||
|
|
3b1e69f2a5 | ||
|
|
f23f127d3b | ||
|
|
1f9295dead | ||
|
|
c15613d7fe | ||
|
|
4ef5a95b4f | ||
|
|
e43ef0acf0 | ||
|
|
a0698ed888 | ||
|
|
25380c0618 | ||
|
|
bef5e8479f | ||
|
|
d478c92fc8 | ||
|
|
334409ffa8 | ||
|
|
fdcaeee8ee | ||
|
|
dbf2d23905 | ||
|
|
9cc1607811 | ||
|
|
7e2ffc575f | ||
|
|
60c272acb7 | ||
|
|
fe83a62615 | ||
|
|
4dd962f002 | ||
|
|
5c58f2a9c2 | ||
|
|
5e9aaecf9c | ||
|
|
7d23cfc7eb | ||
|
|
0af736df0e |
@@ -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
|
||||
@@ -1,161 +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.
|
||||
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:
|
||||
|
||||
The plugin is available as a Python package and can be installed with pip.
|
||||
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.
|
||||
|
||||
Run `pip install netbox-topology-views` in your virtual env.
|
||||
## Vorschau
|
||||
|
||||
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:
|
||||
Die klassische Topologieansicht stammt aus dem ursprünglichen Plugin:
|
||||
|
||||
```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 |
|
||||
| -------------- | ----------------------------- |
|
||||
| >= 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 |
|
||||
|
||||
### Custom field: coordinates
|
||||
|
||||
There is also support for custom fields.
|
||||
|
||||
If you create a custom field "coordinates" for "dcim > device" and "Circuits > circuit" with type "text" and name "coordinates" you will see the same layout every time. It is recommended to set this field to "UI visibility" "Hidden" and let the plugin manage it in the background.
|
||||
|
||||
The coordinates are stored as: "X;Y".
|
||||
|
||||
Please read the "Configure" chapter to set the `allow_coordinates_saving` option to True.
|
||||
You might also set the `always_save_coordinates` option to True.
|
||||
|
||||
## Configure
|
||||
|
||||
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 use the custom coordinates fields and want to save the coordinates |
|
||||
| always_save_coordinates | False | (bool) Set if you want to enable the option to save coordinates by default |
|
||||
Danach werden Migrationen und statische Dateien verarbeitet:
|
||||
|
||||
### Custom Images
|
||||
```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
|
||||
```
|
||||
|
||||
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.
|
||||
Nach Änderungen an CSS oder Icons kann ein Hard-Reload des Browsers mit
|
||||
`Strg+F5` erforderlich sein.
|
||||
|
||||
## Use
|
||||
## Aktualisierung
|
||||
|
||||
Go to the plugins tab in the navbar and click topology or go to `$NETBOX_URL/plugins/netbox_topology_views/` to view your topologies
|
||||
```bash
|
||||
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
|
||||
"git+https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views.git@develop"
|
||||
|
||||
Select your options for the topology view:
|
||||
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
|
||||
```
|
||||
|
||||

|
||||
`--force-reinstall` ist derzeit sinnvoll, da der Fork intern noch die
|
||||
Paketversion `4.5.1` verwendet.
|
||||
|
||||
<dl>
|
||||
<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 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 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 Locigal 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 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>
|
||||
|
||||
### Update
|
||||
## Verwendung
|
||||
|
||||
Run `pip install netbox-topology-views --upgrade` in your venv.
|
||||
### Klassische Topologie
|
||||
|
||||
Run `python3 manage.py migrate netbox_topology_views`
|
||||
Unter **Plugins → Topology Views → Topology** steht die ursprüngliche,
|
||||
filterbare Topologieansicht zur Verfügung. Sie unterstützt unter anderem:
|
||||
|
||||
Run `python3 manage.py collectstatic --no-input`
|
||||
- physische Kabel
|
||||
- logische Verbindungen
|
||||
- Nachbargeräte
|
||||
- Circuit-Terminations
|
||||
- Power Feeds
|
||||
- Wireless Links
|
||||
- Coordinate Groups
|
||||
- PNG- und XML-Export
|
||||
|
||||
### Rack-Ansicht
|
||||
|
||||
Clear you browser cache.
|
||||
Die erweiterte Ansicht befindet sich unter:
|
||||
|
||||
**Plugins → Topology Views → Weitere Ansichten → Rack-Ansicht**
|
||||
|
||||
### Permissions
|
||||
Über Site, Location und Rack können ein einzelnes Rack oder mehrere Racks
|
||||
ausgewählt werden. Optional lassen sich auch leere Racks einblenden.
|
||||
|
||||
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
|
||||
In der Elevation werden Geräte entsprechend ihrer Position, Gerätehöhe und
|
||||
Rack-Seite dargestellt. Unter den Elevations folgen:
|
||||
|
||||
To view `/plugins/netbox_topology-views/image`:
|
||||
+ dcim | site | view
|
||||
+ dcim | device role | view
|
||||
+ dcim | device role | add
|
||||
+ dcim | device role | change
|
||||
1. die interaktive Kabeltopologie und
|
||||
2. eine tabellarische Übersicht sämtlicher betroffener Kabel.
|
||||
|
||||
To view `/plugins/netbox_topology-views/individualoptions`:
|
||||
+ netbox topology views | individualoptions | change
|
||||
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.
|
||||
|
||||
## Icons info
|
||||
Ü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.
|
||||
|
||||
Power icons created by [Freepik - Flaticon](https://www.flaticon.com/free-icons/power).
|
||||
Die Positionen werden im `localStorage` des Browsers gespeichert. Sie gelten
|
||||
damit nur für den jeweiligen Browser und die konkrete Rack-/Filterauswahl.
|
||||
|
||||
Power icons created by [Flat Icons - Flaticon](https://www.flaticon.com/free-icons/power)
|
||||
## Geräte-Icons
|
||||
|
||||
Provider icons created by [Good Ware - Flaticon](https://www.flaticon.com/free-icons/provider)
|
||||
Das Plugin ordnet Icons anhand des Slugs einer Device Role zu. Dieser Fork
|
||||
liefert zusätzlich folgende SVG-Dateien aus:
|
||||
|
||||
| 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` |
|
||||
|
||||
Eigene Bilder können weiterhin über die Seite **Images** zugeordnet werden.
|
||||
Der Standardpfad lautet `netbox_topology_views/img` innerhalb von
|
||||
`STATIC_ROOT`.
|
||||
|
||||
## Berechtigungen
|
||||
|
||||
Für die klassische Topologie werden mindestens folgende Rechte benötigt:
|
||||
|
||||
- `dcim.view_site`
|
||||
- `dcim.view_device`
|
||||
|
||||
Für die Rack-Ansicht werden benötigt:
|
||||
|
||||
- `dcim.view_rack`
|
||||
- `dcim.view_device`
|
||||
- `dcim.view_cable`
|
||||
|
||||
Für das Speichern klassischer Topologiekoordinaten werden zusätzlich die
|
||||
entsprechenden Rechte auf `netbox_topology_views.coordinate` benötigt.
|
||||
|
||||
## Kompatibilität
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 260 KiB |
@@ -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
|
||||
@@ -1,12 +1,12 @@
|
||||
from extras.plugins import PluginConfig
|
||||
from netbox.plugins import PluginConfig
|
||||
|
||||
|
||||
class TopologyViewsConfig(PluginConfig):
|
||||
name = "netbox_topology_views"
|
||||
verbose_name = "Topology views"
|
||||
description = "An plugin to render topology maps"
|
||||
version = "3.4.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 = []
|
||||
|
||||
@@ -2,7 +2,7 @@ from dcim.models import Device, DeviceRole
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from netbox.api.serializers import NetBoxModelSerializer
|
||||
|
||||
from netbox_topology_views.models import RoleImage, IndividualOptions
|
||||
from netbox_topology_views.models import RoleImage, IndividualOptions, CoordinateGroup, Coordinate, CircuitCoordinate, PowerPanelCoordinate, PowerFeedCoordinate
|
||||
|
||||
|
||||
class TopologyDummySerializer(ModelSerializer):
|
||||
@@ -22,7 +22,35 @@ class DeviceRoleSerializer(ModelSerializer):
|
||||
model = DeviceRole
|
||||
fields = ("name", "slug", "color", "vm_role", "description")
|
||||
|
||||
class CoordinateGroupSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = CoordinateGroup
|
||||
fields = ("name", "description")
|
||||
|
||||
class CoordinateSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = Coordinate
|
||||
fields = ("x", "y")
|
||||
|
||||
class CircuitCoordinateSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = CircuitCoordinate
|
||||
fields = ("x", "y")
|
||||
|
||||
class PowerPanelCoordinateSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = PowerPanelCoordinate
|
||||
fields = ("x", "y")
|
||||
|
||||
class PowerFeedCoordinateSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = PowerFeedCoordinate
|
||||
fields = ("x", "y")
|
||||
|
||||
class IndividualOptionsSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = IndividualOptions
|
||||
fields = ("ignore_cable_type", "show_unconnected", "show_cables", "show_logical_connections", "show_single_cable_logical_conns", "show_circuit", "show_power", "show_wireless", "draw_default_layout")
|
||||
fields = ("ignore_cable_type", "save_coords", "show_unconnected", "show_cables", "show_logical_connections",
|
||||
"show_single_cable_logical_conns", "show_neighbors", "show_circuit", "show_power", "show_wireless", "group_sites",
|
||||
"group_locations", "group_racks", "group_virtualchassis", "draw_default_layout", "straight_cables",
|
||||
"draw_termination_labels", "draw_cable_labels", "grid_size", "node_label_items")
|
||||
|
||||
@@ -6,5 +6,11 @@ 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
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from typing import Dict
|
||||
import sys
|
||||
|
||||
from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet
|
||||
|
||||
from circuits.models import Circuit
|
||||
from dcim.models import Device, DeviceRole, PowerFeed, PowerPanel
|
||||
from extras.models import SavedFilter
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import JsonResponse
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
|
||||
@@ -13,12 +17,21 @@ from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
|
||||
from netbox_topology_views.api.serializers import (
|
||||
RoleImageSerializer,
|
||||
TopologyDummySerializer,
|
||||
CoordinateGroupSerializer,
|
||||
CoordinateSerializer,
|
||||
CircuitCoordinateSerializer,
|
||||
PowerPanelCoordinateSerializer,
|
||||
PowerFeedCoordinateSerializer,
|
||||
)
|
||||
from netbox_topology_views.models import RoleImage
|
||||
from netbox_topology_views.utils import get_image_from_url
|
||||
import netbox_topology_views.models
|
||||
from netbox_topology_views.models import RoleImage, IndividualOptions, CoordinateGroup, Coordinate, CircuitCoordinate, PowerPanelCoordinate, PowerFeedCoordinate
|
||||
from netbox_topology_views.views import get_topology_data
|
||||
from netbox_topology_views.utils import get_image_from_url, export_data_to_xml, get_query_settings
|
||||
from netbox_topology_views.filters import DeviceFilterSet
|
||||
|
||||
class SaveCoordsViewSet(BaseViewSet, ReadOnlyModelViewSet):
|
||||
permission_required = 'netbox_topology_views.change_coordinate'
|
||||
|
||||
class SaveCoordsViewSet(ReadOnlyModelViewSet):
|
||||
queryset = Device.objects.none()
|
||||
serializer_class = TopologyDummySerializer
|
||||
|
||||
@@ -32,57 +45,181 @@ class SaveCoordsViewSet(ReadOnlyModelViewSet):
|
||||
device_id: str = request.data.get("node_id", None)
|
||||
x_coord = request.data.get("x", None)
|
||||
y_coord = request.data.get("y", None)
|
||||
group_id = request.data.get("group", "None")
|
||||
|
||||
actual_device = None
|
||||
if device_id.startswith("c"):
|
||||
device_id = device_id.lstrip("c")
|
||||
actual_device = Circuit.objects.get(id=device_id)
|
||||
model_name = 'CircuitCoordinate'
|
||||
elif device_id.startswith("p"):
|
||||
device_id = device_id.lstrip("p")
|
||||
actual_device = PowerPanel.objects.get(id=device_id)
|
||||
model_name = 'PowerPanelCoordinate'
|
||||
elif device_id.startswith("f"):
|
||||
device_id = device_id.lstrip("f")
|
||||
actual_device = PowerFeed.objects.get(id=device_id)
|
||||
model_name = 'PowerFeedCoordinate'
|
||||
elif device_id.isnumeric():
|
||||
actual_device = Device.objects.get(id=device_id)
|
||||
model_name = 'Coordinate'
|
||||
|
||||
if not actual_device:
|
||||
return Response({"status": "invalid node_id in body"}, status=400)
|
||||
|
||||
model_class = getattr(netbox_topology_views.models, model_name)
|
||||
|
||||
if group_id is None or group_id == "default":
|
||||
group_id = model_class.get_or_create_default_group(group_id)
|
||||
if not group_id:
|
||||
return Response(
|
||||
{"status": "Error while creating default group."}, status=500
|
||||
)
|
||||
|
||||
try:
|
||||
actual_device.custom_field_data["coordinates"] = "%s;%s" % (
|
||||
x_coord,
|
||||
y_coord,
|
||||
)
|
||||
actual_device.save()
|
||||
if CoordinateGroup.objects.filter(pk=group_id):
|
||||
group = CoordinateGroup.objects.get(pk=group_id)
|
||||
# Hen-and-egg-problem. Thanks, Django! By default, Django updates records that
|
||||
# already exist and inserts otherwise. This does not work with our
|
||||
# unique_together key if no pk is given. But: No record, no pk.
|
||||
if not model_class.objects.filter(group=group, device=actual_device):
|
||||
# Unique group/device pair does not exist. Prepare new data set
|
||||
coords = model_class(group=group, device=actual_device, x=x_coord, y=y_coord)
|
||||
else:
|
||||
# Unique group/device pair already exists. Update data
|
||||
coords = model_class(pk=model_class.objects.get(group=group, device=actual_device).pk, group=group, device=actual_device, x=x_coord, y=y_coord)
|
||||
coords.save()
|
||||
except:
|
||||
return Response(
|
||||
{"status": "coords custom field could not be saved"}, status=500
|
||||
{"status": "Coordinates could not be saved."}, status=500
|
||||
)
|
||||
|
||||
return Response({"status": "saved coords"})
|
||||
|
||||
class ExportTopoToXML(BaseViewSet, ViewSet):
|
||||
queryset = Device.objects.none()
|
||||
serializer_class = TopologyDummySerializer
|
||||
|
||||
class SaveRoleImageViewSet(PermissionRequiredMixin, ViewSet):
|
||||
def list(self, request):
|
||||
|
||||
self.filterset = DeviceFilterSet
|
||||
self.queryset = Device.objects.restrict(request.user, 'view').select_related(
|
||||
"device_type", "role"
|
||||
)
|
||||
self.queryset = self.filterset(request.GET, self.queryset).qs
|
||||
|
||||
individualOptions, created = IndividualOptions.objects.get_or_create(
|
||||
user_id=request.user.id,
|
||||
)
|
||||
|
||||
if request.GET:
|
||||
|
||||
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"] != '':
|
||||
try:
|
||||
saved_filter = SavedFilter.objects.get(pk=filter_id)
|
||||
saved_filter_params = getattr(saved_filter, 'parameters')
|
||||
|
||||
if ignore_cable_type == () and 'ignore_cable_type' in saved_filter_params: ignore_cable_type = saved_filter_params['ignore_cable_type']
|
||||
if save_coords == False and 'save_coords' in saved_filter_params: save_coords = saved_filter_params['save_coords']
|
||||
if show_unconnected == False and 'show_unconnected' in saved_filter_params: show_unconnected = saved_filter_params['show_unconnected']
|
||||
if show_power == False and 'show_power' in saved_filter_params: show_power = saved_filter_params['show_power']
|
||||
if show_circuit == False and 'show_circuit' in saved_filter_params: show_circuit = saved_filter_params['show_circuit']
|
||||
if show_logical_connections == False and 'show_logical_connections' in saved_filter_params: show_logical_connections = saved_filter_params['show_logical_connections']
|
||||
if show_single_cable_logical_conns == False and 'show_single_cable_logical_conns' in saved_filter_params: show_single_cable_logical_conns = saved_filter_params['show_single_cable_logical_conns']
|
||||
if show_cables == False and 'show_cables' in saved_filter_params: show_cables = saved_filter_params['show_cables']
|
||||
if show_wireless == False and 'show_wireless' in saved_filter_params: show_wireless = saved_filter_params['show_wireless']
|
||||
if group_sites == False and 'group_sites' in saved_filter_params: group_sites = saved_filter_params['group_sites']
|
||||
if group_locations == False and 'group_locations' in saved_filter_params: group_locations = saved_filter_params['group_locations']
|
||||
if group_racks == False and 'group_racks' in saved_filter_params: group_racks = saved_filter_params['group_racks']
|
||||
if group_virtualchassis == False and 'group_virtualchassis' in saved_filter_params: group_virtualchassis = saved_filter_params['group_virtualchassis']
|
||||
if show_neighbors == False and 'show_neighbors' in saved_filter_params: show_neighbors = saved_filter_params['show_neighbors']
|
||||
if straight_cables == False and 'straight_cables' in saved_filter_params: straight_cables = saved_filter_params['straight_cables']
|
||||
if 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:
|
||||
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,
|
||||
show_logical_connections=show_logical_connections,
|
||||
show_single_cable_logical_conns=show_single_cable_logical_conns,
|
||||
show_neighbors=show_neighbors,
|
||||
show_circuit=show_circuit,
|
||||
show_power=show_power,
|
||||
show_wireless=show_wireless,
|
||||
group_sites=group_sites,
|
||||
group_locations=group_locations,
|
||||
group_racks=group_racks,
|
||||
group_virtualchassis=group_virtualchassis,
|
||||
group_id=group_id,
|
||||
straight_cables=straight_cables,
|
||||
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').replace('\n', '
')
|
||||
|
||||
return HttpResponse(xml_data, content_type="application/xml; charset=utf-8")
|
||||
else:
|
||||
return JsonResponse(
|
||||
{"status": "Missing or malformed request parameters"}, status=400
|
||||
)
|
||||
|
||||
class SaveRoleImageViewSet(BaseViewSet, ReadOnlyModelViewSet):
|
||||
queryset = DeviceRole.objects.none()
|
||||
serializer_class = RoleImageSerializer
|
||||
permission_required = (
|
||||
"dcim.add_device_role",
|
||||
"dcim.change_device_role",
|
||||
"dcim.add_devicerole",
|
||||
"dcim.change_devicerole",
|
||||
)
|
||||
|
||||
def create(self, request):
|
||||
@action(detail=False, methods=["post"])
|
||||
def save(self, request):
|
||||
if not isinstance(request.data, dict):
|
||||
return JsonResponse(
|
||||
{"status": "Missing or malformed request body"}, status=400
|
||||
)
|
||||
|
||||
device_roles = {k: v for k, v in request.data.items() if k.isnumeric()}
|
||||
content_type_ids = {
|
||||
k[2:]: v
|
||||
for k, v in request.data.items()
|
||||
if k.startswith("ct") and k[2:].isnumeric()
|
||||
}
|
||||
if sys.version_info >= (3,9,0):
|
||||
device_roles = {
|
||||
k: v.removeprefix(settings.STATIC_URL)
|
||||
for k, v in request.data.items()
|
||||
if k.isnumeric()}
|
||||
content_type_ids = {
|
||||
k[2:]: v.removeprefix(settings.STATIC_URL)
|
||||
for k, v in request.data.items()
|
||||
if k.startswith("ct") and k[2:].isnumeric()
|
||||
}
|
||||
else:
|
||||
device_roles = {}
|
||||
for k, v in request.data.items():
|
||||
if k.isdigit() and v.startswith(settings.STATIC_URL):
|
||||
device_roles[k] = v[len(settings.STATIC_URL):]
|
||||
|
||||
content_type_ids = {}
|
||||
for k, v in request.data.items():
|
||||
if k.startswith("ct") and k[2:].isdigit() and v.startswith(settings.STATIC_URL):
|
||||
content_type_ids[k[2:]] = v[len(settings.STATIC_URL):]
|
||||
|
||||
roles: Dict[int, DeviceRole] = DeviceRole.objects.in_bulk(device_roles.keys())
|
||||
content_types: Dict[int, ContentType] = ContentType.objects.in_bulk(
|
||||
@@ -126,3 +263,23 @@ class SaveRoleImageViewSet(PermissionRequiredMixin, ViewSet):
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -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,28 +1,56 @@
|
||||
import django_filters
|
||||
from circuits.models import Circuit
|
||||
from dcim.choices import DeviceStatusChoices
|
||||
from dcim.models import Device, DeviceRole, Location, Rack, Region, Site
|
||||
from dcim.models import Device, DeviceRole, Location, Rack, Region, Site, SiteGroup, Manufacturer, DeviceType, Platform, PowerPanel, PowerFeed
|
||||
from django.db.models import Q
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
from extras.models import ConfigTemplate
|
||||
from netbox.filtersets import NetBoxModelFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from utilities.filters import TreeNodeMultipleChoiceFilter
|
||||
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
|
||||
from utilities.filters import TreeNodeMultipleChoiceFilter, MultiValueCharFilter, MultiValueMACAddressFilter
|
||||
from netbox_topology_views.models import CoordinateGroup, Coordinate, CircuitCoordinate, PowerPanelCoordinate, PowerFeedCoordinate
|
||||
|
||||
|
||||
class DeviceFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method="search",
|
||||
label="Search",
|
||||
)
|
||||
device_role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name="device_role_id",
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device_type__manufacturer',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
label="Manufacturer (ID)",
|
||||
)
|
||||
manufacturer = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device_type__manufacturer__slug',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
to_field_name='slug',
|
||||
label="Manufacturer (slug)",
|
||||
)
|
||||
device_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=DeviceType.objects.all(),
|
||||
label="Device type (ID)",
|
||||
)
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name="role_id",
|
||||
queryset=DeviceRole.objects.all(),
|
||||
label="Role (ID)",
|
||||
)
|
||||
platform_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Platform.objects.all(),
|
||||
label="Platform (ID)",
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name="site__region",
|
||||
lookup_expr="in",
|
||||
label="Region (ID)",
|
||||
)
|
||||
site_group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
field_name="site__group",
|
||||
lookup_expr="in",
|
||||
label="Site Group (ID)",
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Site.objects.all(),
|
||||
label="Site (ID)",
|
||||
@@ -42,10 +70,57 @@ class DeviceFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
choices=DeviceStatusChoices,
|
||||
null_value=None,
|
||||
)
|
||||
mac_address = MultiValueMACAddressFilter(
|
||||
field_name='interfaces__mac_addresses__mac_address',
|
||||
label="MAC address",
|
||||
)
|
||||
serial = MultiValueCharFilter(
|
||||
lookup_expr='iexact'
|
||||
)
|
||||
console_ports = django_filters.BooleanFilter(
|
||||
method='_console_ports',
|
||||
label="Has console ports",
|
||||
)
|
||||
console_server_ports = django_filters.BooleanFilter(
|
||||
method='_console_server_ports',
|
||||
label="Has console server ports",
|
||||
)
|
||||
power_ports = django_filters.BooleanFilter(
|
||||
method='_power_ports',
|
||||
label="Has power ports",
|
||||
)
|
||||
power_outlets = django_filters.BooleanFilter(
|
||||
method='_power_outlets',
|
||||
label="Has power outlets",
|
||||
)
|
||||
interfaces = django_filters.BooleanFilter(
|
||||
method='_interfaces',
|
||||
label="Has interfaces",
|
||||
)
|
||||
pass_through_ports = django_filters.BooleanFilter(
|
||||
method='_pass_through_ports',
|
||||
label="Has pass-through ports",
|
||||
)
|
||||
config_template_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
label="Config template (ID)",
|
||||
)
|
||||
has_primary_ip = django_filters.BooleanFilter(
|
||||
method='_has_primary_ip',
|
||||
label="Has a primary IP",
|
||||
)
|
||||
has_oob_ip = django_filters.BooleanFilter(
|
||||
method='_has_oob_ip',
|
||||
label="Has an out-of-band IP",
|
||||
)
|
||||
virtual_chassis_member = django_filters.BooleanFilter(
|
||||
method='_virtual_chassis_member',
|
||||
label="Is a virtual chassis member",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ["id", "name"]
|
||||
fields = ["id", "name", "asset_tag", "airflow"]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
"""Perform the filtered search."""
|
||||
@@ -53,3 +128,127 @@ class DeviceFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
return queryset
|
||||
qs_filter = Q(name__icontains=value)
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
def _console_ports(self, queryset, name, value):
|
||||
return queryset.exclude(consoleports__isnull=value)
|
||||
|
||||
def _console_server_ports(self, queryset, name, value):
|
||||
return queryset.exclude(consoleserverports__isnull=value)
|
||||
|
||||
def _power_ports(self, queryset, name, value):
|
||||
return queryset.exclude(powerports__isnull=value)
|
||||
|
||||
def _power_outlets(self, queryset, name, value):
|
||||
return queryset.exclude(poweroutlets__isnull=value)
|
||||
|
||||
def _interfaces(self, queryset, name, value):
|
||||
return queryset.exclude(interfaces__isnull=value)
|
||||
|
||||
def _pass_through_ports(self, queryset, name, value):
|
||||
return queryset.exclude(
|
||||
frontports__isnull=value,
|
||||
rearports__isnull=value
|
||||
)
|
||||
|
||||
def _has_primary_ip(self, queryset, name, value):
|
||||
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
|
||||
if value:
|
||||
return queryset.filter(params)
|
||||
return queryset.exclude(params)
|
||||
|
||||
def _has_oob_ip(self, queryset, name, value):
|
||||
params = Q(oob_ip__isnull=False)
|
||||
if value:
|
||||
return queryset.filter(params)
|
||||
return queryset.exclude(params)
|
||||
|
||||
def _virtual_chassis_member(self, queryset, name, value):
|
||||
return queryset.exclude(virtual_chassis__isnull=value)
|
||||
|
||||
class CircuitCoordinatesFilterSet(NetBoxModelFilterSet):
|
||||
group = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset = CoordinateGroup.objects.all(),
|
||||
)
|
||||
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset = Circuit.objects.all(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CircuitCoordinate
|
||||
fields = ['id', 'group', 'device', 'x', 'y']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
"""Perform the filtered search."""
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(group__name__icontains=value) |
|
||||
Q(device__name__icontains=value)
|
||||
)
|
||||
|
||||
class PowerPanelCoordinatesFilterSet(NetBoxModelFilterSet):
|
||||
group = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset = CoordinateGroup.objects.all(),
|
||||
)
|
||||
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset = PowerPanel.objects.all(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerPanelCoordinate
|
||||
fields = ['id', 'group', 'device', 'x', 'y']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
"""Perform the filtered search."""
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(group__name__icontains=value) |
|
||||
Q(device__name__icontains=value)
|
||||
)
|
||||
|
||||
class PowerFeedCoordinatesFilterSet(NetBoxModelFilterSet):
|
||||
group = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset = CoordinateGroup.objects.all(),
|
||||
)
|
||||
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset = PowerFeed.objects.all(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerFeedCoordinate
|
||||
fields = ['id', 'group', 'device', 'x', 'y']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
"""Perform the filtered search."""
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(group__name__icontains=value) |
|
||||
Q(device__name__icontains=value)
|
||||
)
|
||||
|
||||
class CoordinatesFilterSet(NetBoxModelFilterSet):
|
||||
group = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset = CoordinateGroup.objects.all(),
|
||||
)
|
||||
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset = Device.objects.all(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Coordinate
|
||||
fields = ['id', 'group', 'device', 'x', 'y']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
"""Perform the filtered search."""
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(group__name__icontains=value) |
|
||||
Q(device__name__icontains=value)
|
||||
)
|
||||
|
||||
@@ -1,252 +1,766 @@
|
||||
from cProfile import label
|
||||
import imp
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.models import Device, Site, Region, DeviceRole, Location, Rack
|
||||
from circuits.models import Circuit
|
||||
from dcim.models import Device, Site, SiteGroup, Region, DeviceRole, Location, Rack, Manufacturer, DeviceType, Platform, PowerPanel, PowerFeed
|
||||
|
||||
from django import forms
|
||||
from dcim.choices import DeviceStatusChoices
|
||||
from dcim.choices import DeviceStatusChoices, DeviceAirflowChoices
|
||||
from tenancy.models import TenantGroup, Tenant
|
||||
from tenancy.forms import TenancyFilterForm
|
||||
from extras.models import ConfigTemplate
|
||||
from extras.forms import LocalConfigContextFilterForm
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
from django.conf import settings
|
||||
from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm
|
||||
from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm, NetBoxModelImportForm
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
|
||||
from utilities.forms.fields import (
|
||||
TagFilterField,
|
||||
DynamicModelMultipleChoiceField,
|
||||
MultipleChoiceField
|
||||
DynamicModelMultipleChoiceField
|
||||
)
|
||||
|
||||
from netbox_topology_views.models import IndividualOptions, CoordinateGroup, Coordinate, CircuitCoordinate, PowerPanelCoordinate, PowerFeedCoordinate
|
||||
from netbox_topology_views.choices import NodeLabelItems
|
||||
|
||||
from .models import IndividualOptions
|
||||
|
||||
class DeviceFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
class DeviceFilterForm(
|
||||
LocalConfigContextFilterForm,
|
||||
TenancyFilterForm,
|
||||
ContactModelFilterForm,
|
||||
NetBoxModelFilterSetForm
|
||||
):
|
||||
model = Device
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
(
|
||||
"q",
|
||||
"filter_id",
|
||||
"save_coords",
|
||||
"show_unconnected",
|
||||
"show_cables",
|
||||
"show_circuit",
|
||||
"show_logical_connections",
|
||||
"show_single_cable_logical_conns",
|
||||
"show_power",
|
||||
"show_wireless",
|
||||
),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet(
|
||||
'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")
|
||||
),
|
||||
(
|
||||
None,
|
||||
(
|
||||
"tenant_group_id",
|
||||
"tenant_id",
|
||||
),
|
||||
FieldSet(
|
||||
'show_circuit', 'show_power', name=_("Additional filter-independent types (non-devices)")
|
||||
),
|
||||
(None, ("region_id", "site_id", "location_id", "rack_id")),
|
||||
(
|
||||
None,
|
||||
(
|
||||
"device_role_id",
|
||||
"id",
|
||||
"status",
|
||||
),
|
||||
FieldSet('id', name=_("Device")),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_("Location")),
|
||||
FieldSet('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_("Operation")),
|
||||
FieldSet('manufacturer_id', 'device_type_id', 'platform_id', name=_("Hardware")),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_("Tenant")),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_("Contacts")),
|
||||
FieldSet(
|
||||
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', name=_("Components")
|
||||
),
|
||||
FieldSet(
|
||||
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data', name=_("Miscellaneous")
|
||||
),
|
||||
(None, ("tag",)),
|
||||
)
|
||||
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(), required=False, label=_("Region")
|
||||
)
|
||||
device_role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceRole.objects.all(), required=False, label=_("Device Role")
|
||||
group = forms.ModelChoiceField(
|
||||
queryset=CoordinateGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Coordinate group'),
|
||||
)
|
||||
id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
label=_("Device"),
|
||||
label=_('Device'),
|
||||
query_params={
|
||||
"location_id": "$location_id",
|
||||
"region_id": "$region_id",
|
||||
"site_id": "$site_id",
|
||||
"role_id": "$device_role_id",
|
||||
'location_id': '$location_id',
|
||||
'region_id': '$region_id',
|
||||
'site_group_id': '$site_group_id',
|
||||
'site_id': '$site_id',
|
||||
'role_id': '$role_id',
|
||||
'contact': '$contact',
|
||||
'contact_role': '$contact_role',
|
||||
'contact_group': '$contact_group',
|
||||
},
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site Group'),
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
"region_id": "$region_id",
|
||||
'region_id': '$region_id',
|
||||
'group_id': '$site_group_id',
|
||||
},
|
||||
label=_("Site"),
|
||||
label=_('Site'),
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
"region_id": "$region_id",
|
||||
"site_id": "$site_id",
|
||||
'region_id': '$region_id',
|
||||
'site_group_id': '$site_group_id',
|
||||
'site_id': '$site_id',
|
||||
},
|
||||
label=_("Location"),
|
||||
label=_('Location'),
|
||||
)
|
||||
rack_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
"region_id": "$region_id",
|
||||
"site_id": "$site_id",
|
||||
"location_id": "$location_id",
|
||||
'region_id': '$region_id',
|
||||
'site_group_id': '$site_group_id',
|
||||
'site_id': '$site_id',
|
||||
'location_id': '$location_id',
|
||||
},
|
||||
label=_("Rack"),
|
||||
label=_('Rack'),
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
choices=DeviceStatusChoices, required=False, label=_("Device Status")
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=DeviceStatusChoices,
|
||||
required=False,
|
||||
label=_('Device Status')
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceRole.objects.all(),
|
||||
required=False,
|
||||
label=_('Role')
|
||||
)
|
||||
airflow = forms.MultipleChoiceField(
|
||||
label=_('Airflow'),
|
||||
choices=add_blank_choice(DeviceAirflowChoices),
|
||||
required=False
|
||||
)
|
||||
serial = forms.CharField(
|
||||
label=_('Serial'),
|
||||
required=False
|
||||
)
|
||||
asset_tag = forms.CharField(
|
||||
label=_('Asset tag'),
|
||||
required=False
|
||||
)
|
||||
mac_address = forms.CharField(
|
||||
required=False,
|
||||
label=_('MAC address')
|
||||
)
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label=_('Manufacturer')
|
||||
)
|
||||
device_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'manufacturer_id': '$manufacturer_id'
|
||||
},
|
||||
label=_('Model')
|
||||
)
|
||||
platform_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Platform.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
label=_('Platform')
|
||||
)
|
||||
console_ports = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Has console ports'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
console_server_ports = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Has console server ports'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
power_ports = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Has power ports'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
power_outlets = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Has power outlets'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
interfaces = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Has interfaces'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
pass_through_ports = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Has pass-through ports'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
config_template_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
required=False,
|
||||
label=_('Config template')
|
||||
)
|
||||
has_primary_ip = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Has a primary IP'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
has_oob_ip = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Has an OOB IP'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
virtual_chassis_member = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Virtual chassis member'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
# options
|
||||
save_coords = forms.BooleanField(
|
||||
label=_("Save Coordinates"),
|
||||
ignore_cable_type = forms.MultipleChoiceField(
|
||||
label=_('Ignore Termination Types'),
|
||||
required=False,
|
||||
disabled=(not settings.PLUGINS_CONFIG["netbox_topology_views"]["allow_coordinates_saving"] or settings.PLUGINS_CONFIG["netbox_topology_views"]["always_save_coordinates"]),
|
||||
initial=(settings.PLUGINS_CONFIG["netbox_topology_views"]["always_save_coordinates"])
|
||||
choices=IndividualOptions.CHOICES
|
||||
)
|
||||
show_unconnected = forms.BooleanField(
|
||||
label=_("Show Unconnected"), required=False, initial=False
|
||||
save_coords = forms.NullBooleanField(
|
||||
label=_('Save Coordinates'),
|
||||
required=False,
|
||||
disabled=(not settings.PLUGINS_CONFIG['netbox_topology_views']['allow_coordinates_saving'] or settings.PLUGINS_CONFIG['netbox_topology_views']['always_save_coordinates']),
|
||||
initial=(settings.PLUGINS_CONFIG['netbox_topology_views']['always_save_coordinates']),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
show_logical_connections = forms.BooleanField(
|
||||
label =_("Show Logical Connections"), required=False, initial=False
|
||||
show_unconnected = forms.NullBooleanField(
|
||||
label=_('Show Unconnected'),
|
||||
required=False,
|
||||
initial=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
show_single_cable_logical_conns = forms.BooleanField(
|
||||
label =_("Show redundant Cable and Locigal Connection"), required=False, initial=False
|
||||
|
||||
show_cables = forms.NullBooleanField(
|
||||
required=False,
|
||||
initial=False,
|
||||
label=_('Show Cables'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
show_cables = forms.BooleanField(
|
||||
label =_("Show Cables"), required=False, initial=False
|
||||
show_wireless = forms.NullBooleanField(
|
||||
label =_('Show Wireless Links'), required=False, initial=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
show_wireless = forms.BooleanField(
|
||||
label =_("Show Wireless Links"), required=False, initial=False
|
||||
show_logical_connections = forms.NullBooleanField(
|
||||
label =_('Show Logical Connections'), required=False, initial=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
show_circuit = forms.BooleanField(
|
||||
label=_("Show Circuit Terminations"), required=False, initial=False
|
||||
show_single_cable_logical_conns = forms.NullBooleanField(
|
||||
label =_('Show redundant Cable and Logical Connection'), required=False, initial=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
show_power = forms.BooleanField(
|
||||
label=_("Show Power Feeds"), required=False, initial=False
|
||||
show_neighbors = forms.NullBooleanField(
|
||||
label =_('Show Neighbors'), 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(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
group_locations = forms.NullBooleanField(
|
||||
label =_('Group Locations'), required=False, initial=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
group_racks = forms.NullBooleanField(
|
||||
label =_('Group Racks'), required=False, initial=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
group_virtualchassis = forms.NullBooleanField(
|
||||
label =_('Group Virtual Chassis'), required=False, initial=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
straight_cables = forms.BooleanField(
|
||||
label=_('Straight Cables'), required=False, initial=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
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 = (
|
||||
FieldSet('name', 'description', name=_("Group Details")),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CoordinateGroup
|
||||
fields = ('name', 'description')
|
||||
|
||||
class CoordinateGroupsImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = CoordinateGroup
|
||||
fields = ('name', 'description')
|
||||
|
||||
class CircuitCoordinatesForm(NetBoxModelForm):
|
||||
fieldsets = (
|
||||
FieldSet('group', 'device', 'x', 'y', name=_("Circuit Coordinate")),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CircuitCoordinate
|
||||
fields = ('group', 'device', 'x', 'y')
|
||||
|
||||
class PowerPanelCoordinatesForm(NetBoxModelForm):
|
||||
fieldsets = (
|
||||
FieldSet('group', 'device', 'x', 'y', name=_("Power Panel")),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerPanelCoordinate
|
||||
fields = ('group', 'device', 'x', 'y')
|
||||
|
||||
class PowerFeedCoordinatesForm(NetBoxModelForm):
|
||||
fieldsets = (
|
||||
FieldSet('group', 'device', 'x', 'y', name=_("PowerFeed Coordinate")),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerFeedCoordinate
|
||||
fields = ('group', 'device', 'x', 'y')
|
||||
|
||||
class CoordinatesForm(NetBoxModelForm):
|
||||
fieldsets = (
|
||||
FieldSet('group', 'device', 'x', 'y', name=_("Coordinate")),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Coordinate
|
||||
fields = ('group', 'device', 'x', 'y')
|
||||
|
||||
class CircuitCoordinatesImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = CircuitCoordinate
|
||||
fields = ('group', 'device', 'x', 'y')
|
||||
|
||||
class PowerPanelCoordinatesImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = PowerPanelCoordinate
|
||||
fields = ('group', 'device', 'x', 'y')
|
||||
|
||||
class PowerFeedCoordinatesImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = PowerFeedCoordinate
|
||||
fields = ('group', 'device', 'x', 'y')
|
||||
|
||||
class CoordinatesImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = Coordinate
|
||||
fields = ('group', 'device', 'x', 'y')
|
||||
|
||||
class CircuitCoordinatesFilterForm(NetBoxModelFilterSetForm):
|
||||
model = CircuitCoordinate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('group', 'device', 'x', 'y', name=_("Circuit Coordinates")),
|
||||
)
|
||||
|
||||
group = forms.ModelMultipleChoiceField(
|
||||
queryset=CoordinateGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
device = DynamicModelMultipleChoiceField(
|
||||
queryset=Circuit.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
x = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
|
||||
y = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
|
||||
class PowerPanelCoordinatesFilterForm(NetBoxModelFilterSetForm):
|
||||
model = PowerPanelCoordinate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('group', 'device', 'x', 'y', name=_('PowerPanel Coordinates')),
|
||||
)
|
||||
|
||||
group = forms.ModelMultipleChoiceField(
|
||||
queryset=CoordinateGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
device = DynamicModelMultipleChoiceField(
|
||||
queryset=PowerPanel.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
x = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
|
||||
y = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
|
||||
class PowerFeedCoordinatesFilterForm(NetBoxModelFilterSetForm):
|
||||
model = Coordinate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('group', 'device', 'x', 'y', name=_("PowerFeed Coordinates")),
|
||||
)
|
||||
|
||||
group = forms.ModelMultipleChoiceField(
|
||||
queryset=CoordinateGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
device = DynamicModelMultipleChoiceField(
|
||||
queryset=PowerFeed.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
x = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
|
||||
y = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
|
||||
class CoordinatesFilterForm(NetBoxModelFilterSetForm):
|
||||
model = Coordinate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('group', 'device', 'x', 'y', name=_("Coordinates")),
|
||||
)
|
||||
|
||||
group = forms.ModelMultipleChoiceField(
|
||||
queryset=CoordinateGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
device = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
x = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
|
||||
y = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
|
||||
class IndividualOptionsForm(NetBoxModelForm):
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
FieldSet
|
||||
(
|
||||
"user_id",
|
||||
"ignore_cable_type",
|
||||
"preselected_device_roles",
|
||||
"preselected_tags",
|
||||
"show_unconnected",
|
||||
"show_cables",
|
||||
"show_circuit",
|
||||
"show_logical_connections",
|
||||
"show_single_cable_logical_conns",
|
||||
"show_power",
|
||||
"show_wireless",
|
||||
"draw_default_layout",
|
||||
'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',
|
||||
'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)")
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
user_id = forms.CharField(widget=forms.HiddenInput())
|
||||
|
||||
ignore_cable_type = MultipleChoiceField(
|
||||
label=_("Ignore Termination Types"),
|
||||
required=False,
|
||||
ignore_cable_type = forms.MultipleChoiceField(
|
||||
label=_('Ignore Termination Types'),
|
||||
required=False,
|
||||
choices=IndividualOptions.CHOICES,
|
||||
help_text=_("Choose Termination Types that you want to be ignored. "
|
||||
"If any ignored Termination Type is part of a connection, the "
|
||||
"cable is not displayed.")
|
||||
help_text=_('Choose Termination Types that you want to be ignored. '
|
||||
'If any ignored Termination Type is part of a connection, the '
|
||||
'cable is not displayed.')
|
||||
)
|
||||
preselected_device_roles = DynamicModelMultipleChoiceField(
|
||||
label=_("Preselected Device Role"),
|
||||
label=_('Preselected Device Role'),
|
||||
queryset=DeviceRole.objects.all(),
|
||||
required=False,
|
||||
help_text=_("Select Device Roles that you want to have "
|
||||
"preselected in the filter tab.")
|
||||
help_text=_('Select Device Roles that you want to have '
|
||||
'preselected in the filter tab.')
|
||||
)
|
||||
preselected_tags = forms.ModelMultipleChoiceField(
|
||||
label=_("Preselected Tags"),
|
||||
label=_('Preselected Tags'),
|
||||
queryset=Device.tags.all(),
|
||||
required=False,
|
||||
help_text=_("Select Tags that you want to have "
|
||||
"preselected in the filter tab.")
|
||||
help_text=_('Select Tags that you want to have '
|
||||
'preselected in the filter tab.')
|
||||
)
|
||||
show_unconnected = forms.BooleanField(
|
||||
label=_("Show Unconnected"),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_("Draws devices that have no connections or for which no "
|
||||
"connection is displayed. This option depends on other parameters "
|
||||
"like 'Show Cables' and 'Show Logical Connections'.")
|
||||
)
|
||||
show_cables = forms.BooleanField(
|
||||
label =_("Show Cables"),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_("Displays connections between interfaces that are connected "
|
||||
"with one or more cables. These connections are displayed as solid "
|
||||
"lines in the color of the cable.")
|
||||
)
|
||||
show_logical_connections = forms.BooleanField(
|
||||
label =_("Show Logical Connections"),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_("Displays connections between devices that are not "
|
||||
"directly connected (e.g. via patch panels). These connections "
|
||||
"are displayed as yellow dotted lines.")
|
||||
)
|
||||
show_single_cable_logical_conns = forms.BooleanField(
|
||||
label = ("Show redundant Cable and Locigal Connection"),
|
||||
required = False,
|
||||
initial=False,
|
||||
help_text=_("Shows a logical connection (in addition to a cable), "
|
||||
"even if a cable is directly connected. Leaving this option "
|
||||
"disabled prevents that redundant display. This option only "
|
||||
"has an effect if 'Show Logical Connections' is activated.")
|
||||
)
|
||||
show_circuit = forms.BooleanField(
|
||||
label=_("Show Circuit Terminations"),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_("Displays connections between circuit terminations. "
|
||||
"These connections are displayed as blue dashed lines.")
|
||||
)
|
||||
show_power = forms.BooleanField(
|
||||
label=_("Show Power Feeds"),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_("Displays connections between power outlets and power "
|
||||
"ports. These connections are displayed as solid lines in the "
|
||||
"color of the cable. This option depends on 'Show Cables'.")
|
||||
)
|
||||
show_wireless = forms.BooleanField(
|
||||
label =_("Show Wireless Links"),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_("Displays wireless connections. These connections are "
|
||||
"displayed as blue dotted lines.")
|
||||
)
|
||||
draw_default_layout = forms.BooleanField(
|
||||
label = ("Draw Default Layout"),
|
||||
save_coords = forms.BooleanField(
|
||||
label=_('Save Coordinates'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_("Enable this option if you want to draw the topology on "
|
||||
"the initial load (when you go to the topology plugin page).")
|
||||
help_text=_('Coordinates of nodes will be saved if dragged to a different '
|
||||
'position. This option depends on parameters set in the config file. '
|
||||
'It has no effect if \'allow_coordinates_saving\' has not been set or '
|
||||
' \'always_save_coordinates\' has been set.')
|
||||
)
|
||||
show_unconnected = forms.BooleanField(
|
||||
label=_('Show Unconnected'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_('Draws devices that have no connections or for which no '
|
||||
'connection is displayed. This option depends on other parameters '
|
||||
'like \'Show Cables\' and \'Show Logical Connections\'.')
|
||||
)
|
||||
show_cables = forms.BooleanField(
|
||||
label =_('Show Cables'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_('Displays connections between interfaces that are connected '
|
||||
'with one or more cables. These connections are displayed as solid '
|
||||
'lines in the color of the cable.')
|
||||
)
|
||||
show_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,
|
||||
initial=False,
|
||||
help_text=_('Displays connections between devices that are not '
|
||||
'directly connected (e.g. via patch panels). These connections '
|
||||
'are displayed as yellow dotted lines.')
|
||||
)
|
||||
show_single_cable_logical_conns = forms.BooleanField(
|
||||
label = ('Show redundant Cable and Logical Connection'),
|
||||
required = False,
|
||||
initial=False,
|
||||
help_text=_('Shows a logical connection (in addition to a cable), '
|
||||
'even if a cable is directly connected. Leaving this option '
|
||||
'disabled prevents that redundant display. This option only '
|
||||
'has an effect if \'Show Logical Connections\' is activated.')
|
||||
)
|
||||
show_neighbors = forms.BooleanField(
|
||||
label =_('Show Neighbors'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_('Adds neighbors to the filter result set automatically. '
|
||||
'Link peers will be added if \'Show Cables\' is ticked, far-end '
|
||||
'terminations will be added if \'Show Logical Connections\' is ticked.')
|
||||
)
|
||||
group_sites = forms.BooleanField(
|
||||
label =_('Group Sites'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_('Draws a rectangle around Devices that belong to the '
|
||||
'same site.')
|
||||
)
|
||||
group_locations = forms.BooleanField(
|
||||
label =_('Group Locations'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_('Draws a rectangle around Devices that belong to the '
|
||||
'same location.')
|
||||
)
|
||||
group_racks = forms.BooleanField(
|
||||
label =_('Group Racks'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_('Draws a rectangle around Devices that belong to the '
|
||||
'same rack.')
|
||||
)
|
||||
group_virtualchassis = forms.BooleanField(
|
||||
label =_('Group Virtual Chassis'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_('Draws a rectangle around Devices that belong to the '
|
||||
'same virtual chassis.')
|
||||
)
|
||||
draw_default_layout = forms.BooleanField(
|
||||
label = ('Draw Default Layout'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_('Enable this option if you want to draw the topology on '
|
||||
'the initial load (when you go to the topology plugin page).')
|
||||
)
|
||||
straight_cables = forms.BooleanField(
|
||||
label=('Use Straight Cables'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_('Enable this option if you want to draw cables as straight lines '
|
||||
'instead of curves.')
|
||||
)
|
||||
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', 'show_unconnected', 'show_cables', 'show_logical_connections', 'show_single_cable_logical_conns', 'show_circuit', 'show_power', 'show_wireless', 'draw_default_layout'
|
||||
]
|
||||
'user_id', 'ignore_cable_type', 'preselected_device_roles', 'preselected_tags',
|
||||
'save_coords', 'show_unconnected', 'show_cables', 'show_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.1.8 on 2023-04-29 09:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('netbox_topology_views', '0002_individualoptions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='individualoptions',
|
||||
name='show_neighbors',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 4.1.8 on 2023-05-01 16:13
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import taggit.managers
|
||||
import utilities.json
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('netbox_topology_views', '0003_individualoptions_show_neighbors'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CoordinateGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('description', models.CharField(blank=True, max_length=255)),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Coordinate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
|
||||
('x', models.IntegerField()),
|
||||
('y', models.IntegerField()),
|
||||
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.device')),
|
||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='netbox_topology_views.coordinategroup')),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['group', 'device'],
|
||||
'unique_together': {('device', 'group')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.8 on 2023-05-23 20:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('netbox_topology_views', '0004_coordinategroup_coordinate'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='individualoptions',
|
||||
name='save_coords',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
# Generated by Django 4.1.8 on 2023-09-25 21:18
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import taggit.managers
|
||||
import utilities.json
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('netbox_topology_views', '0005_individualoptions_save_coords'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PowerPanelCoordinate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
|
||||
('x', models.IntegerField()),
|
||||
('y', models.IntegerField()),
|
||||
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.powerpanel')),
|
||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='netbox_topology_views.coordinategroup')),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['group', 'device'],
|
||||
'unique_together': {('device', 'group')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PowerFeedCoordinate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
|
||||
('x', models.IntegerField()),
|
||||
('y', models.IntegerField()),
|
||||
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.powerfeed')),
|
||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='netbox_topology_views.coordinategroup')),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['group', 'device'],
|
||||
'unique_together': {('device', 'group')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CircuitCoordinate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
|
||||
('x', models.IntegerField()),
|
||||
('y', models.IntegerField()),
|
||||
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='circuits.circuit')),
|
||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='netbox_topology_views.coordinategroup')),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['group', 'device'],
|
||||
'unique_together': {('device', 'group')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 4.2.10 on 2024-03-02 16:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('netbox_topology_views', '0006_powerpanelcoordinate_powerfeedcoordinate_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='individualoptions',
|
||||
name='group_locations',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='individualoptions',
|
||||
name='group_racks',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='individualoptions',
|
||||
name='group_sites',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.10 on 2024-04-29 14:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('netbox_topology_views', '0007_individualoptions_group_locations_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='individualoptions',
|
||||
name='straight_cables',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.6 on 2024-07-29 08:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('netbox_topology_views', '0008_straight_cables'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='individualoptions',
|
||||
name='group_virtualchassis',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.6 on 2024-08-14 09:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('netbox_topology_views', '0009_individualoptions_group_virtualchassis'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='individualoptions',
|
||||
name='grid_size',
|
||||
field=models.PositiveSmallIntegerField(default=0),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.9 on 2024-09-27 18:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('netbox_topology_views', '0010_individualoptions_grid_size'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='individualoptions',
|
||||
name='node_label_items',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -1,17 +1,19 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from dcim.models import DeviceRole
|
||||
from circuits.models import Circuit
|
||||
from dcim.models import Device, DeviceRole, PowerPanel, PowerFeed
|
||||
from extras.models import Tag
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse
|
||||
from netbox.models import NetBoxModel
|
||||
from netbox.models.features import (
|
||||
ChangeLoggingMixin,
|
||||
ExportTemplatesMixin,
|
||||
WebhooksMixin,
|
||||
EventRulesMixin,
|
||||
)
|
||||
|
||||
from netbox_topology_views.utils import (
|
||||
@@ -23,8 +25,9 @@ from netbox_topology_views.utils import (
|
||||
image_static_url,
|
||||
)
|
||||
|
||||
from netbox_topology_views.choices import NodeLabelItems
|
||||
|
||||
class RoleImage(ChangeLoggingMixin, ExportTemplatesMixin, WebhooksMixin):
|
||||
class RoleImage(ChangeLoggingMixin, ExportTemplatesMixin, EventRulesMixin):
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
@@ -39,6 +42,8 @@ class RoleImage(ChangeLoggingMixin, ExportTemplatesMixin, WebhooksMixin):
|
||||
|
||||
__role: Optional[Role] = None
|
||||
|
||||
_netbox_private = True
|
||||
|
||||
@property
|
||||
def role(self) -> Role:
|
||||
if self.__role:
|
||||
@@ -94,15 +99,245 @@ class RoleImage(ChangeLoggingMixin, ExportTemplatesMixin, WebhooksMixin):
|
||||
return self.get_default_image(dir)
|
||||
return static(f"/{self.image}")
|
||||
|
||||
class CoordinateGroup(NetBoxModel):
|
||||
"""
|
||||
A coordinate group is used to display the topology for a particular group.
|
||||
This allows different visualizations with the same devices.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
)
|
||||
|
||||
description = models.CharField(
|
||||
max_length=255,
|
||||
blank = True,
|
||||
)
|
||||
|
||||
_netbox_private = True
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('plugins:netbox_topology_views:coordinategroup', args=[self.pk])
|
||||
|
||||
class Coordinate(NetBoxModel):
|
||||
"""
|
||||
Coordinates are being used to place devices in a topology view onto a certain
|
||||
position. Devices belong to one or more coordinate groups. They have to
|
||||
be unique together.
|
||||
"""
|
||||
device = models.ForeignKey(Device, on_delete=models.CASCADE)
|
||||
group = models.ForeignKey(CoordinateGroup, on_delete=models.CASCADE)
|
||||
|
||||
x = models.IntegerField(
|
||||
help_text='X-coordinate of the device (horizontal) on the canvas. '
|
||||
'Smaller values correspond to a position further to the left on the monitor.',
|
||||
)
|
||||
y = models.IntegerField(
|
||||
help_text='Y-coordinate of the device (vertical) on the canvas. '
|
||||
'Smaller values correspond to a position further up on the monitor.',
|
||||
)
|
||||
|
||||
_netbox_private = True
|
||||
|
||||
def get_or_create_default_group(group_id):
|
||||
# Default group named "default" must always exist in order to make sure
|
||||
# that coordinate values can be stored even if no coordinate group has been
|
||||
# selected. The default group will be added automatically if it does not exist.
|
||||
try:
|
||||
if CoordinateGroup.objects.filter(name="default"):
|
||||
group = CoordinateGroup.objects.get(name="default")
|
||||
group_id = group.pk
|
||||
else:
|
||||
group = CoordinateGroup(
|
||||
name="default",
|
||||
description="Automatically generated default group. If you delete "
|
||||
"this group, all default coordinates are gone for good but "
|
||||
"the group itself will be re-created."
|
||||
)
|
||||
group.save()
|
||||
group_id = group.pk
|
||||
except:
|
||||
return False
|
||||
return group_id
|
||||
|
||||
class Meta:
|
||||
ordering = ['group', 'device']
|
||||
unique_together = ('device', 'group')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.x};{self.y}'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('plugins:netbox_topology_views:coordinate', args=[self.pk])
|
||||
|
||||
class CircuitCoordinate(NetBoxModel):
|
||||
"""
|
||||
Coordinates are being used to place devices in a topology view onto a certain
|
||||
position. Devices belong to one or more coordinate groups. They have to
|
||||
be unique together.
|
||||
"""
|
||||
device = models.ForeignKey(Circuit, on_delete=models.CASCADE)
|
||||
group = models.ForeignKey(CoordinateGroup, on_delete=models.CASCADE)
|
||||
|
||||
x = models.IntegerField(
|
||||
help_text='X-coordinate of the device (horizontal) on the canvas. '
|
||||
'Smaller values correspond to a position further to the left on the monitor.',
|
||||
)
|
||||
y = models.IntegerField(
|
||||
help_text='Y-coordinate of the device (vertical) on the canvas. '
|
||||
'Smaller values correspond to a position further up on the monitor.',
|
||||
)
|
||||
|
||||
_netbox_private = True
|
||||
|
||||
def get_or_create_default_group(group_id):
|
||||
# Default group named "default" must always exist in order to make sure
|
||||
# that coordinate values can be stored even if no coordinate group has been
|
||||
# selected. The default group will be added automatically if it does not exist.
|
||||
try:
|
||||
if CoordinateGroup.objects.filter(name="default"):
|
||||
group = CoordinateGroup.objects.get(name="default")
|
||||
group_id = group.pk
|
||||
else:
|
||||
group = CoordinateGroup(
|
||||
name="default",
|
||||
description="Automatically generated default group. If you delete "
|
||||
"this group, all default coordinates are gone for good but "
|
||||
"the group itself will be re-created."
|
||||
)
|
||||
group.save()
|
||||
group_id = group.pk
|
||||
except:
|
||||
return False
|
||||
return group_id
|
||||
|
||||
class Meta:
|
||||
ordering = ['group', 'device']
|
||||
unique_together = ('device', 'group')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.x};{self.y}'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('plugins:netbox_topology_views:circuitcoordinate', args=[self.pk])
|
||||
|
||||
class PowerPanelCoordinate(NetBoxModel):
|
||||
"""
|
||||
Coordinates are being used to place devices in a topology view onto a certain
|
||||
position. Devices belong to one or more coordinate groups. They have to
|
||||
be unique together.
|
||||
"""
|
||||
device = models.ForeignKey(PowerPanel, on_delete=models.CASCADE)
|
||||
group = models.ForeignKey(CoordinateGroup, on_delete=models.CASCADE)
|
||||
|
||||
x = models.IntegerField(
|
||||
help_text='X-coordinate of the device (horizontal) on the canvas. '
|
||||
'Smaller values correspond to a position further to the left on the monitor.',
|
||||
)
|
||||
y = models.IntegerField(
|
||||
help_text='Y-coordinate of the device (vertical) on the canvas. '
|
||||
'Smaller values correspond to a position further up on the monitor.',
|
||||
)
|
||||
|
||||
_netbox_private = True
|
||||
|
||||
def get_or_create_default_group(group_id):
|
||||
# Default group named "default" must always exist in order to make sure
|
||||
# that coordinate values can be stored even if no coordinate group has been
|
||||
# selected. The default group will be added automatically if it does not exist.
|
||||
try:
|
||||
if CoordinateGroup.objects.filter(name="default"):
|
||||
group = CoordinateGroup.objects.get(name="default")
|
||||
group_id = group.pk
|
||||
else:
|
||||
group = CoordinateGroup(
|
||||
name="default",
|
||||
description="Automatically generated default group. If you delete "
|
||||
"this group, all default coordinates are gone for good but "
|
||||
"the group itself will be re-created."
|
||||
)
|
||||
group.save()
|
||||
group_id = group.pk
|
||||
except:
|
||||
return False
|
||||
return group_id
|
||||
|
||||
class Meta:
|
||||
ordering = ['group', 'device']
|
||||
unique_together = ('device', 'group')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.x};{self.y}'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('plugins:netbox_topology_views:powerpanelcoordinate', args=[self.pk])
|
||||
|
||||
class PowerFeedCoordinate(NetBoxModel):
|
||||
"""
|
||||
Coordinates are being used to place devices in a topology view onto a certain
|
||||
position. Devices belong to one or more coordinate groups. They have to
|
||||
be unique together.
|
||||
"""
|
||||
device = models.ForeignKey(PowerFeed, on_delete=models.CASCADE)
|
||||
group = models.ForeignKey(CoordinateGroup, on_delete=models.CASCADE)
|
||||
|
||||
x = models.IntegerField(
|
||||
help_text='X-coordinate of the device (horizontal) on the canvas. '
|
||||
'Smaller values correspond to a position further to the left on the monitor.',
|
||||
)
|
||||
y = models.IntegerField(
|
||||
help_text='Y-coordinate of the device (vertical) on the canvas. '
|
||||
'Smaller values correspond to a position further up on the monitor.',
|
||||
)
|
||||
|
||||
_netbox_private = True
|
||||
|
||||
def get_or_create_default_group(group_id):
|
||||
# Default group named "default" must always exist in order to make sure
|
||||
# that coordinate values can be stored even if no coordinate group has been
|
||||
# selected. The default group will be added automatically if it does not exist.
|
||||
try:
|
||||
if CoordinateGroup.objects.filter(name="default"):
|
||||
group = CoordinateGroup.objects.get(name="default")
|
||||
group_id = group.pk
|
||||
else:
|
||||
group = CoordinateGroup(
|
||||
name="default",
|
||||
description="Automatically generated default group. If you delete "
|
||||
"this group, all default coordinates are gone for good but "
|
||||
"the group itself will be re-created."
|
||||
)
|
||||
group.save()
|
||||
group_id = group.pk
|
||||
except:
|
||||
return False
|
||||
return group_id
|
||||
|
||||
class Meta:
|
||||
ordering = ['group', 'device']
|
||||
unique_together = ('device', 'group')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.x};{self.y}'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('plugins:netbox_topology_views:powerfeedcoordinate', args=[self.pk])
|
||||
|
||||
class IndividualOptions(NetBoxModel):
|
||||
CHOICES = (
|
||||
('interface', 'interface'),
|
||||
('front port', 'front port'),
|
||||
('rear port', 'rear port'),
|
||||
('power outlet', 'power outlet'),
|
||||
('power port', 'power port'),
|
||||
('console port', 'console port'),
|
||||
('console server port', 'console server port'),
|
||||
('frontport', 'frontport'),
|
||||
('rearport', 'rearport'),
|
||||
('poweroutlet', 'poweroutlet'),
|
||||
('powerport', 'powerport'),
|
||||
('consoleport', 'consoleport'),
|
||||
('consoleserverport', 'consoleserverport'),
|
||||
)
|
||||
|
||||
user_id = models.IntegerField(
|
||||
@@ -125,6 +360,9 @@ class IndividualOptions(NetBoxModel):
|
||||
blank=True,
|
||||
db_table='netbox_topology_views_individualoptions_preselected_tag',
|
||||
)
|
||||
save_coords = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
show_unconnected = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
@@ -137,6 +375,9 @@ class IndividualOptions(NetBoxModel):
|
||||
show_single_cable_logical_conns = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
show_neighbors = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
show_circuit = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
@@ -146,9 +387,39 @@ class IndividualOptions(NetBoxModel):
|
||||
show_wireless = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
group_sites = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
group_locations = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
group_racks = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
group_virtualchassis = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
draw_default_layout = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
straight_cables = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
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):
|
||||
return f"{self.user_id}"
|
||||
return f"{self.user_id}"
|
||||
|
||||
@@ -1,14 +1,107 @@
|
||||
from extras.plugins import PluginMenu, PluginMenuItem
|
||||
from netbox.plugins import PluginMenu, PluginMenuItem, PluginMenuButton
|
||||
|
||||
coordinategroup_buttons = (
|
||||
PluginMenuButton(
|
||||
link='plugins:netbox_topology_views:coordinategroup_add',
|
||||
title='Add',
|
||||
icon_class='mdi mdi-plus-thick',
|
||||
permissions=['netbox_topology_views.add_coordinategroup']
|
||||
),
|
||||
PluginMenuButton(
|
||||
link='plugins:netbox_topology_views:coordinategroup_import',
|
||||
title='Import',
|
||||
icon_class='mdi mdi-upload',
|
||||
permissions=['netbox_topology_views.add_coordinategroup']
|
||||
)
|
||||
)
|
||||
|
||||
circuitcoordinate_buttons = (
|
||||
PluginMenuButton(
|
||||
link='plugins:netbox_topology_views:circuitcoordinate_add',
|
||||
title='Add',
|
||||
icon_class='mdi mdi-plus-thick',
|
||||
permissions=['netbox_topology_views.add_coordinate']
|
||||
),
|
||||
PluginMenuButton(
|
||||
link='plugins:netbox_topology_views:circuitcoordinate_import',
|
||||
title='Import',
|
||||
icon_class='mdi mdi-upload',
|
||||
permissions=['netbox_topology_views.add_coordinate']
|
||||
)
|
||||
)
|
||||
|
||||
powerpanelcoordinate_buttons = (
|
||||
PluginMenuButton(
|
||||
link='plugins:netbox_topology_views:powerpanelcoordinate_add',
|
||||
title='Add',
|
||||
icon_class='mdi mdi-plus-thick',
|
||||
permissions=['netbox_topology_views.add_coordinate']
|
||||
),
|
||||
PluginMenuButton(
|
||||
link='plugins:netbox_topology_views:powerpanelcoordinate_import',
|
||||
title='Import',
|
||||
icon_class='mdi mdi-upload',
|
||||
permissions=['netbox_topology_views.add_coordinate']
|
||||
)
|
||||
)
|
||||
|
||||
powerfeedcoordinate_buttons = (
|
||||
PluginMenuButton(
|
||||
link='plugins:netbox_topology_views:powerfeedcoordinate_add',
|
||||
title='Add',
|
||||
icon_class='mdi mdi-plus-thick',
|
||||
permissions=['netbox_topology_views.add_coordinate']
|
||||
),
|
||||
PluginMenuButton(
|
||||
link='plugins:netbox_topology_views:powerfeedcoordinate_import',
|
||||
title='Import',
|
||||
icon_class='mdi mdi-upload',
|
||||
permissions=['netbox_topology_views.add_coordinate']
|
||||
)
|
||||
)
|
||||
|
||||
coordinate_buttons = (
|
||||
PluginMenuButton(
|
||||
link='plugins:netbox_topology_views:coordinate_add',
|
||||
title='Add',
|
||||
icon_class='mdi mdi-plus-thick',
|
||||
permissions=['netbox_topology_views.add_coordinate']
|
||||
),
|
||||
PluginMenuButton(
|
||||
link='plugins:netbox_topology_views:coordinate_import',
|
||||
title='Import',
|
||||
icon_class='mdi mdi-upload',
|
||||
permissions=['netbox_topology_views.add_coordinate']
|
||||
)
|
||||
)
|
||||
|
||||
menu = PluginMenu(
|
||||
label='Topology Views',
|
||||
icon_class="mdi mdi-sitemap",
|
||||
groups=(
|
||||
('TOPOLOGY', (PluginMenuItem(link="plugins:netbox_topology_views:home", link_text="Topology"),),),
|
||||
('TOPOLOGY',
|
||||
(
|
||||
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']),
|
||||
PluginMenuItem(link="plugins:netbox_topology_views:coordinate_list", link_text="Device Coordinates", buttons=coordinate_buttons, permissions=['netbox_topology_views.view_coordinate']),
|
||||
PluginMenuItem(link="plugins:netbox_topology_views:powerfeedcoordinate_list", link_text="Power Feed Coords", buttons=powerfeedcoordinate_buttons, permissions=['netbox_topology_views.view_coordinate']),
|
||||
PluginMenuItem(link="plugins:netbox_topology_views:powerpanelcoordinate_list", link_text="Power Panel Coords", buttons=powerpanelcoordinate_buttons, permissions=['netbox_topology_views.view_coordinate']),
|
||||
PluginMenuItem(link="plugins:netbox_topology_views:circuitcoordinate_list", link_text="Circuit Coordinates", buttons=circuitcoordinate_buttons, permissions=['netbox_topology_views.view_coordinate']),
|
||||
),
|
||||
),
|
||||
('PREFERENCES',
|
||||
(
|
||||
PluginMenuItem(link="plugins:netbox_topology_views:images", link_text="Images"),
|
||||
PluginMenuItem(link="plugins:netbox_topology_views:individualoptions", link_text="Individual Options"),
|
||||
PluginMenuItem(link="plugins:netbox_topology_views:images", link_text="Images", permissions=[ "dcim.view_site","dcim.view_devicerole"]),
|
||||
PluginMenuItem(link="plugins:netbox_topology_views:individualoptions", link_text="Individual Options", permissions=['netbox_topology_views.change_individualoptions']),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from netbox.search import SearchIndex, register_search
|
||||
from .models import CoordinateGroup, Coordinate
|
||||
|
||||
@register_search
|
||||
class CoordinateGroupsIndex(SearchIndex):
|
||||
model = CoordinateGroup
|
||||
fields = (
|
||||
('name', 100),
|
||||
('description', 2000),
|
||||
)
|
||||
|
||||
@register_search
|
||||
class CoordinatesIndex(SearchIndex):
|
||||
model = Coordinate
|
||||
fields = (
|
||||
('group', 100),
|
||||
('device', 200),
|
||||
)
|
||||
@@ -1 +1 @@
|
||||
#visgraph{height:64vh}html[data-netbox-color-mode=dark] #visgraph{background-color:#212529}.image-dropdown img{width:64px;height:64px}.image-dropdown-content{display:flex;flex-wrap:wrap;gap:.5rem;padding-inline:.5rem;width:50vw;max-width:32rem}.image-dropdown-content>img{cursor:pointer}
|
||||
#visgraph{height:64vh;border:1px solid #ced4da}html[data-netbox-color-mode=dark] #visgraph{background-color:#212529;border:1px solid #495057}.image-dropdown img{width:64px;height:64px}.image-dropdown-content{display:flex;flex-wrap:wrap;gap:.5rem;padding-inline:.5rem;width:50vw;max-width:32rem}.image-dropdown-content>img{cursor:pointer}.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 |
@@ -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("/api/plugins/netbox_topology_views/images/",{method:"POST",body:JSON.stringify(g),headers:{"X-CSRFToken":p,"Content-Type":"application/json"}});if(!t.ok)throw new Error(yield t.text());u.success("Saved settings")}catch(t){console.dir(t),u.error(t.message)}}));document.querySelectorAll("form#images .dropdown-menu img").forEach(e=>{e.addEventListener("click",t=>{var c;if(!(t.currentTarget instanceof HTMLElement))return;let{dataset:{role:o,image:n}}=t.currentTarget;g[o]=n;let r=(c=t.currentTarget.closest(".dropdown"))==null?void 0:c.querySelector(`#dropdownMenuButton${o}`);r&&(r.innerHTML=`<img src="${n}" />`)})});})();
|
||||
(()=>{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,6CAA8C,CAClE,OAAQ,OACR,KAAM,KAAK,UAAU,GACrB,QAAS,CACL,cAAe,EACf,eAAgB,sBAIxB,GAAI,CAAC,EAAI,GAAI,KAAM,IAAI,OAAM,KAAM,GAAI,QACvC,EAAM,QAAQ,wBACT,EAAP,CACE,QAAQ,IAAI,GACZ,EAAM,MAAM,EAAI,aAIxB,SAAS,iBAAiB,kCAAkC,QAAQ,AAAC,GAAO,CACxE,EAAG,iBAAiB,QAAS,AAAC,GAAM,CA3BxC,MA4BQ,GAAI,CAAE,GAAE,wBAAyB,cAAc,OAC/C,GAAM,CACF,QAAS,CAAE,OAAM,UACjB,EAAE,cAEN,EAAQ,GAAQ,EAEhB,GAAM,GAAS,KAAE,cACZ,QAAQ,eADE,cAET,cAAc,sBAAsB,KAC1C,AAAI,GAAQ,GAAO,UAAY,aAAa",
|
||||
"names": []
|
||||
"sources": ["../../../static_dev/js/toast.js", "../../../static_dev/js/images.js"],
|
||||
"mappings": "oTAAA,IAAaA,EAAbC,EAAAC,EAAA,KAAaF,EAAQ,CACjB,QAAUG,GAAY,CAClB,IAAMC,EAAK,SAAS,cAAc,gCAAgC,EAClE,GAAI,CAACA,EAAI,OAAO,QAAQ,MAAM,iCAAiC,EAC/D,IAAMC,EAAUD,EAAG,cAAc,MAAM,EACvCC,EAAQ,YAAcF,EACR,IAAI,OAAO,MAAMC,CAAE,EAC3B,KAAK,CACf,EACA,MAAQD,GAAY,CAChB,IAAMC,EAAK,SAAS,cAAc,8BAA8B,EAChE,GAAI,CAACA,EAAI,OAAO,QAAQ,MAAM,iCAAiC,EAC/D,IAAMC,EAAUD,EAAG,cAAc,MAAM,EACvCC,EAAQ,YAAcF,EACR,IAAI,OAAO,MAAMC,CAAE,EAC3B,KAAK,CACf,CACJ,ICjBA,IAAAE,EAAAC,EAAAC,GAAA,CAAAC,IAEA,IAAMC,EAAU,CAAC,EAEjB,SAAS,cAAc,aAAa,EAAE,iBAAiB,SAAiBC,GAAMC,EAAA,sBAC1ED,EAAE,eAAe,EACjB,GAAI,CACA,IAAME,EAAM,MAAM,MAAM,IAAM,SAAW,iDAAkD,CACvF,OAAQ,OACR,KAAM,KAAK,UAAUH,CAAO,EAC5B,QAAS,CACL,cAAe,OAAO,WACtB,eAAgB,kBACpB,CACJ,CAAC,EAED,GAAI,CAACG,EAAI,GAAI,MAAM,IAAI,MAAM,MAAMA,EAAI,KAAK,CAAC,EAC7CC,EAAM,QAAQ,gBAAgB,CAClC,OAASC,EAAK,CACV,QAAQ,IAAIA,CAAG,EACfD,EAAM,MAAMC,EAAI,OAAO,CAC3B,CACJ,EAAC,EAED,SAAS,iBAAiB,gCAAgC,EAAE,QAASC,GAAO,CACxEA,EAAG,iBAAiB,QAAUL,GAAM,CAzBxC,IAAAM,EA0BQ,GAAI,EAAEN,EAAE,yBAAyB,aAAc,OAC/C,GAAM,CACF,QAAS,CAAE,KAAAO,EAAM,MAAAC,CAAM,CAC3B,EAAIR,EAAE,cAEND,EAAQQ,CAAI,EAAIC,EAEhB,IAAMC,GAASH,EAAAN,EAAE,cACZ,QAAQ,WAAW,IADT,YAAAM,EAET,cAAc,sBAAsBC,CAAI,IAC1CE,IAAQA,EAAO,UAAY,aAAaD,CAAK,OACrD,CAAC,CACL,CAAC",
|
||||
"names": ["toast", "init_toast", "__esmMin", "message", "el", "content", "require_images", "__commonJSMin", "exports", "init_toast", "mapping", "e", "__async", "res", "toast", "err", "el", "_a", "role", "image", "button"]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
#visgraph {
|
||||
height: 64vh;
|
||||
border: 1px solid #ced4da
|
||||
}
|
||||
|
||||
html[data-netbox-color-mode=dark] #visgraph {
|
||||
background-color: #212529;
|
||||
border: 1px solid #495057
|
||||
}
|
||||
|
||||
|
||||
.image-dropdown img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
@@ -25,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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -60,40 +94,200 @@ const coordSaveCheckbox = document.querySelector('#id_save_coords')
|
||||
}))
|
||||
)
|
||||
|
||||
// make nodes object available globally in order to update their physics and positions later
|
||||
window.nodes = nodes;
|
||||
|
||||
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()
|
||||
|
||||
graph.on('dragEnd', (params) => {
|
||||
if (coordSaveCheckbox == null) return
|
||||
if (!coordSaveCheckbox.checked) return
|
||||
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(
|
||||
Object.entries(graph.getPositions(params.nodes)).map(
|
||||
async ([nodeId, nodePosition]) => {
|
||||
if(!isNaN(parseInt(nodeId))) {
|
||||
nodeKey = parseInt(nodeId);
|
||||
}
|
||||
else {
|
||||
nodeKey = nodeId;
|
||||
}
|
||||
|
||||
try {
|
||||
window.nodes.update({id: nodeKey, physics: false, x: nodePosition.x, y: nodePosition.y});
|
||||
}
|
||||
catch (e) {
|
||||
console.log([
|
||||
'Error while executing window.nodes.update()',
|
||||
'nodeId: ' + nodeId,
|
||||
'nodeKey: ' + nodeKey,
|
||||
'x: ' + nodePosition.x,
|
||||
'y: ' + nodePosition.y
|
||||
]);
|
||||
console.log(e);
|
||||
}
|
||||
const res = await fetch(
|
||||
'/api/plugins/netbox_topology_views/save-coords/save_coords/',
|
||||
'/' + basePath + 'api/plugins/netbox_topology_views/save-coords/save_coords/',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'X-CSRFToken': csrftoken,
|
||||
'X-CSRFToken': window.CSRF_TOKEN,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
node_id: nodeId,
|
||||
x: nodePosition.x,
|
||||
y: nodePosition.y
|
||||
y: nodePosition.y,
|
||||
group: topologyData.group
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
console.log(nodeId, res.status, res.statusText)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -111,6 +305,227 @@ const coordSaveCheckbox = document.querySelector('#id_save_coords')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
graph.on('beforeDrawing', (canvascontext) => {
|
||||
if (gridSize > 0) {
|
||||
drawGrid(canvascontext);
|
||||
}
|
||||
})
|
||||
|
||||
graph.on('afterDrawing', (canvascontext) => {
|
||||
allRectangles = [];
|
||||
if(group_sites != null && group_sites == 'on') { drawGroupRectangles(canvascontext, groupedNodeSites, siteRectParams); }
|
||||
if(group_locations != null && group_locations == 'on') { drawGroupRectangles(canvascontext, groupedNodeLocations, locationRectParams); }
|
||||
if(group_racks != null && group_racks == 'on') { drawGroupRectangles(canvascontext, groupedNodeRacks, rackRectParams); }
|
||||
if(group_virtualchassis != null && group_virtualchassis == 'on') { drawGroupRectangles(canvascontext, groupedNodeVirtualchassis, virtualchassisRectParams); }
|
||||
|
||||
drawGridSnapHint(canvascontext);
|
||||
})
|
||||
|
||||
graph.on('click', (canvascontext) => {
|
||||
allRectangles.forEach(key => {
|
||||
// Is the mouse pointer inside of the current rectangle?
|
||||
if(canvascontext.pointer.canvas.x > (key.x1 - key.border / 2 - 3) && canvascontext.pointer.canvas.x < (key.x2 + key.border / 2 + 3)
|
||||
&& canvascontext.pointer.canvas.y > (key.y1 - key.border / 2 - 3) && canvascontext.pointer.canvas.y < (key.y2 + key.border / 2 + 3)) {
|
||||
// We just want to react when the border has been clicked, not the whole rectangle
|
||||
if (canvascontext.pointer.canvas.x < (key.x1 + key.border / 2 + 3) || canvascontext.pointer.canvas.x > (key.x2 - key.border / 2 - 3)
|
||||
|| canvascontext.pointer.canvas.y < (key.y1 + key.border / 2 + 3) || canvascontext.pointer.canvas.y > (key.y2 - key.border / 2 - 3)) {
|
||||
// Generate an array of affected nodes in order to pass it to the select.Nodes() function
|
||||
let arr = [];
|
||||
if(key.category == "Site") {
|
||||
groupedNodeSites.forEach(subArray => {
|
||||
subArray.forEach(element => {
|
||||
if (element[1] == key.id) {
|
||||
arr.push(element[0]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if(key.category == "Location") {
|
||||
groupedNodeLocations.forEach(subArray => {
|
||||
subArray.forEach(element => {
|
||||
if (element[1] === key.id) {
|
||||
arr.push(element[0]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if(key.category == "Rack") {
|
||||
groupedNodeRacks.forEach(subArray => {
|
||||
subArray.forEach(element => {
|
||||
if (element[1] === key.id) {
|
||||
arr.push(element[0]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if(key.category == "Virtual Chassis") {
|
||||
groupedNodeVirtualchassis.forEach(subArray => {
|
||||
subArray.forEach(element => {
|
||||
if (element[1] === key.id) {
|
||||
arr.push(element[0]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
graph.selectNodes(arr);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// Add information on which node belongs to which group (site/location/rack/virtualchassis).
|
||||
// Create an array for each group in order to loop through that arrays later
|
||||
function combineNodeInfo(typeId, type) {
|
||||
let nodesArray = [];
|
||||
// Extract node ids and node type ids from all nodes
|
||||
for (let [key, value] of nodes._data) {
|
||||
if (value[typeId] != undefined) {
|
||||
nodesArray.push([value.id, value[typeId], value[type], value.label_num_lines]);
|
||||
}
|
||||
}
|
||||
// Split single array above into arrays grouped by node id
|
||||
let groupedNodeArray = nodesArray.reduce((acc, value) => {
|
||||
let key = value[1]; // node id
|
||||
acc[key] = acc[key] || [];
|
||||
acc[key].push(value);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.values(groupedNodeArray);
|
||||
}
|
||||
|
||||
var allRectangles = [];
|
||||
/* Draw a single rectangle with given parameters
|
||||
rectangle expects an object that consists of the following keys:
|
||||
ctx: canvas context on which the rectangle should be drawn
|
||||
x: x-coordinate of top left point of the rectangle
|
||||
y: y-coordinate of top left point of the rectangle
|
||||
width: width of rectangle
|
||||
height: height of rectangle
|
||||
lineWidth: border width
|
||||
color: border color
|
||||
text: a string to be placed where you want it to be
|
||||
textPaddingX: x-position of the text
|
||||
textPaddingY: y-position of the text
|
||||
font: text font */
|
||||
function drawGroupRectangle(rectangle) {
|
||||
// Draw rectangle
|
||||
rectangle.ctx.beginPath();
|
||||
rectangle.ctx.lineWidth = rectangle.lineWidth;
|
||||
rectangle.ctx.strokeStyle = rectangle.color;
|
||||
rectangle.ctx.rect(rectangle.x, rectangle.y, rectangle.width, rectangle.height);
|
||||
rectangle.ctx.stroke();
|
||||
// Draw text
|
||||
rectangle.ctx.font = rectangle.font;
|
||||
rectangle.ctx.fillStyle = rectangle.color;
|
||||
rectangle.ctx.fillText(rectangle.text, rectangle.x + rectangle.textPaddingX, rectangle.y + rectangle.textPaddingY);
|
||||
|
||||
allRectangles.push({category: rectangle.category, id: rectangle.id, x1: rectangle.x, y1: rectangle.y, x2: rectangle.x + rectangle.width, y2: rectangle.y + rectangle.height, border: rectangle.lineWidth})
|
||||
}
|
||||
|
||||
/* Draw all rectangles of a given group (site/location//virtualchassis)
|
||||
rectParams expects an object that consists of the following keys:
|
||||
lineWidth: border width (string)
|
||||
color: border color (string)
|
||||
paddingX: rectangle x-padding, calculated from the center of a node (int)
|
||||
paddingY: rectangle y-padding, calculated from the center of a node (int)
|
||||
textPaddingX: text x-padding, calculated from the lower left point of the text (int)
|
||||
textPaddingY: text y-padding, calculated from the lower left point of the text (int)
|
||||
font: css-like font size and font (string) */
|
||||
function drawGroupRectangles(canvascontext, groupedNodes, rectParams) {
|
||||
for(let value of Object.entries(groupedNodes)) {
|
||||
const rectangles = [];
|
||||
const xValues = [];
|
||||
const yValues = [];
|
||||
const yValuesWithLabel = [];
|
||||
const labelLineHeight = 16;
|
||||
|
||||
// Get coordinates for every node in a given group
|
||||
for(let val of value[1]) {
|
||||
xValues.push(graph.getPosition(val[0]).x);
|
||||
yValues.push(graph.getPosition(val[0]).y);
|
||||
yValuesWithLabel.push(yValues[yValues.length -1] + val[3] * labelLineHeight);
|
||||
}
|
||||
|
||||
const minX = Math.min(...xValues);
|
||||
const maxX = Math.max(...xValues);
|
||||
const minY = Math.min(...yValues);
|
||||
const maxY = Math.max(...yValuesWithLabel);
|
||||
const rectX = minX - rectParams.paddingX;
|
||||
const rectY = minY - rectParams.paddingY;
|
||||
const rectSizeX = maxX - minX + 2*rectParams.paddingX;
|
||||
const rectSizeY = maxY - minY + 2*rectParams.paddingY;
|
||||
|
||||
rectangles.push({
|
||||
ctx: canvascontext,
|
||||
x: rectX,
|
||||
y: rectY,
|
||||
width: rectSizeX,
|
||||
height: rectSizeY,
|
||||
lineWidth: rectParams.lineWidth,
|
||||
color: rectParams.color,
|
||||
text: value[1][0][2],
|
||||
textPaddingX: rectParams.textPaddingX,
|
||||
textPaddingY: rectParams.textPaddingY,
|
||||
font: rectParams.font,
|
||||
id: value[1][0][1],
|
||||
category: rectParams.category
|
||||
});
|
||||
|
||||
rectangles.forEach(function(rectangle) {
|
||||
drawGroupRectangle(rectangle);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let groupedNodeSites = combineNodeInfo('site_id', 'site');
|
||||
let siteRectParams = {
|
||||
lineWidth: "5",
|
||||
color: "red",
|
||||
paddingX: 84,
|
||||
paddingY: 84,
|
||||
textPaddingX: 8,
|
||||
textPaddingY: -8,
|
||||
font: "14px helvetica",
|
||||
category: "Site"
|
||||
}
|
||||
|
||||
let groupedNodeLocations = combineNodeInfo('location_id', 'location');
|
||||
let locationRectParams = {
|
||||
lineWidth: "5",
|
||||
color: "#337ab7",
|
||||
paddingX: 77,
|
||||
paddingY: 77,
|
||||
textPaddingX: 22,
|
||||
textPaddingY: 29,
|
||||
font: "14px helvetica",
|
||||
category: "Location"
|
||||
}
|
||||
|
||||
let groupedNodeRacks = combineNodeInfo('rack_id', 'rack');
|
||||
let rackRectParams = {
|
||||
lineWidth: "5",
|
||||
color: "green",
|
||||
paddingX: 70,
|
||||
paddingY: 70,
|
||||
textPaddingX: 15,
|
||||
textPaddingY: 36,
|
||||
font: "14px helvetica",
|
||||
category: "Rack"
|
||||
}
|
||||
|
||||
let groupedNodeVirtualchassis = combineNodeInfo('virtual_chassis_id', 'virtual_chassis');
|
||||
let virtualchassisRectParams = {
|
||||
lineWidth: "5",
|
||||
color: "orange",
|
||||
paddingX: 63,
|
||||
paddingY: 63,
|
||||
textPaddingX: 8,
|
||||
textPaddingY: 43,
|
||||
font: "14px helvetica",
|
||||
category: "Virtual Chassis"
|
||||
}
|
||||
})()
|
||||
|
||||
// Download Graph
|
||||
@@ -133,23 +548,70 @@ function performGraphDownload() {
|
||||
document.body.removeChild(tempDownloadLink)
|
||||
}
|
||||
|
||||
// Download XML
|
||||
const downloadXmlButton = document.querySelector('#btnDownloadXml')
|
||||
downloadXmlButton.addEventListener('click', (e) => {
|
||||
performXmlDownload()
|
||||
})
|
||||
|
||||
function performXmlDownload() {
|
||||
|
||||
const tempDownloadLink = document.createElement('a');
|
||||
|
||||
let xml_search_options = '';
|
||||
|
||||
if (typeof is_htmx !== 'undefined') {
|
||||
var curr_url = window.location.href;
|
||||
const sites_prefix = '/sites/'
|
||||
const location_prefix = '/locations/'
|
||||
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=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=True&show_unconnected=True'
|
||||
}
|
||||
}
|
||||
else {
|
||||
xml_search_options = new URLSearchParams(window.location.search);
|
||||
}
|
||||
|
||||
|
||||
|
||||
fetch('/' + basePath + 'api/plugins/netbox_topology_views/xml-export/?' + xml_search_options).then(response => response.text())
|
||||
.then(data => {
|
||||
var blob = new Blob([data ], { type: "text/plain" });
|
||||
|
||||
tempDownloadLink.setAttribute("href", window.URL.createObjectURL(blob));
|
||||
tempDownloadLink.setAttribute("download", 'topology.xml');
|
||||
|
||||
tempDownloadLink.dataset.downloadurl = ["text/plain", tempDownloadLink.download, tempDownloadLink.href].join(":");
|
||||
|
||||
tempDownloadLink.click();
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
// Theme switching
|
||||
const observer = new MutationObserver((mutations) =>
|
||||
mutations.forEach((mutation) => {
|
||||
if (
|
||||
!graph ||
|
||||
mutation.type !== 'attributes' ||
|
||||
mutation.attributeName !== 'data-netbox-color-mode' ||
|
||||
mutation.attributeName !== 'data-bs-theme' ||
|
||||
!(mutation.target instanceof HTMLElement)
|
||||
)
|
||||
return
|
||||
const { netboxColorMode } = mutation.target.dataset
|
||||
const netboxColorMode = mutation.target.dataset.bsTheme
|
||||
options.nodes.font.color = netboxColorMode === 'dark' ? '#fff' : '#000'
|
||||
graph.setOptions(options)
|
||||
})
|
||||
)
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-netbox-color-mode']
|
||||
attributeFilter: ['data-bs-theme']
|
||||
})
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { getCookie } from './csrftoken.js'
|
||||
import { toast } from './toast.js'
|
||||
|
||||
const mapping = {}
|
||||
const csrftoken = getCookie('csrftoken')
|
||||
|
||||
document.querySelector('form#images').addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const res = await fetch('/api/plugins/netbox_topology_views/images/', {
|
||||
try {
|
||||
const res = await fetch('/' + basePath + 'api/plugins/netbox_topology_views/images/save/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(mapping),
|
||||
headers: {
|
||||
'X-CSRFToken': csrftoken,
|
||||
'X-CSRFToken': window.CSRF_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "netbox_topology_views",
|
||||
"version": "3.4.0",
|
||||
"version": "4.5.1",
|
||||
"scripts": {
|
||||
"bundle": "node bundle.js",
|
||||
"bundle:styles": "node bundle.js --styles",
|
||||
"bundle:scripts": "node bundle.js --scripts"
|
||||
},
|
||||
"dependencies": {
|
||||
"vis-data": "^7.1.6",
|
||||
"vis-network": "^9.1.6",
|
||||
"vis-util": "^5.0.3"
|
||||
"vis-data": "^7.1.9",
|
||||
"vis-network": "^9.1.9",
|
||||
"vis-util": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.12.24",
|
||||
"esbuild-sass-plugin": "^1.5.2",
|
||||
"@egjs/hammerjs": "^2.0.0",
|
||||
"component-emitter": "^1.3.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild-sass-plugin": "^3.3.1",
|
||||
"keycharm": "^0.4.0",
|
||||
"timsort": "^0.3.0",
|
||||
"uuid": "^8.0.0"
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@egjs/hammerjs@^2.0.0":
|
||||
version "2.0.17"
|
||||
resolved "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz"
|
||||
integrity sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==
|
||||
dependencies:
|
||||
"@types/hammerjs" "^2.0.36"
|
||||
|
||||
"@types/hammerjs@^2.0.36":
|
||||
version "2.0.41"
|
||||
resolved "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.41.tgz"
|
||||
integrity sha512-ewXv/ceBaJprikMcxCmWU1FKyMAQ2X7a9Gtmzw8fcg2kIePI1crERDM818W+XYrxqdBBOdlf2rm137bU+BltCA==
|
||||
|
||||
anymatch@~3.1.2:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz"
|
||||
integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
|
||||
dependencies:
|
||||
normalize-path "^3.0.0"
|
||||
picomatch "^2.0.4"
|
||||
|
||||
binary-extensions@^2.0.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz"
|
||||
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
|
||||
|
||||
braces@~3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz"
|
||||
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
|
||||
dependencies:
|
||||
fill-range "^7.0.1"
|
||||
|
||||
"chokidar@>=3.0.0 <4.0.0":
|
||||
version "3.5.3"
|
||||
resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz"
|
||||
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
|
||||
dependencies:
|
||||
anymatch "~3.1.2"
|
||||
braces "~3.0.2"
|
||||
glob-parent "~5.1.2"
|
||||
is-binary-path "~2.1.0"
|
||||
is-glob "~4.0.1"
|
||||
normalize-path "~3.0.0"
|
||||
readdirp "~3.6.0"
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.2"
|
||||
|
||||
component-emitter@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz"
|
||||
integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
|
||||
|
||||
esbuild-linux-64@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz"
|
||||
integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==
|
||||
|
||||
esbuild-sass-plugin@^1.5.2:
|
||||
version "1.8.2"
|
||||
resolved "https://registry.npmjs.org/esbuild-sass-plugin/-/esbuild-sass-plugin-1.8.2.tgz"
|
||||
integrity sha512-ZBjONsRSpmzMKvxSNohnNXCNaBBVlYLqYhyZtN8leGGkJktCvVMKC6g9V5TWIemk1SEaW2XK+YFxz3Whw3+YYw==
|
||||
dependencies:
|
||||
esbuild "^0.14.5"
|
||||
picomatch "^2.3.0"
|
||||
resolve "^1.20.0"
|
||||
sass "^1.45.0"
|
||||
|
||||
esbuild@^0.12.24:
|
||||
version "0.12.29"
|
||||
resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.12.29.tgz"
|
||||
integrity sha512-w/XuoBCSwepyiZtIRsKsetiLDUVGPVw1E/R3VTFSecIy8UR7Cq3SOtwKHJMFoVqqVG36aGkzh4e8BvpO1Fdc7g==
|
||||
|
||||
esbuild@^0.14.5:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.14.54.tgz"
|
||||
integrity sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==
|
||||
optionalDependencies:
|
||||
"@esbuild/linux-loong64" "0.14.54"
|
||||
esbuild-android-64 "0.14.54"
|
||||
esbuild-android-arm64 "0.14.54"
|
||||
esbuild-darwin-64 "0.14.54"
|
||||
esbuild-darwin-arm64 "0.14.54"
|
||||
esbuild-freebsd-64 "0.14.54"
|
||||
esbuild-freebsd-arm64 "0.14.54"
|
||||
esbuild-linux-32 "0.14.54"
|
||||
esbuild-linux-64 "0.14.54"
|
||||
esbuild-linux-arm "0.14.54"
|
||||
esbuild-linux-arm64 "0.14.54"
|
||||
esbuild-linux-mips64le "0.14.54"
|
||||
esbuild-linux-ppc64le "0.14.54"
|
||||
esbuild-linux-riscv64 "0.14.54"
|
||||
esbuild-linux-s390x "0.14.54"
|
||||
esbuild-netbsd-64 "0.14.54"
|
||||
esbuild-openbsd-64 "0.14.54"
|
||||
esbuild-sunos-64 "0.14.54"
|
||||
esbuild-windows-32 "0.14.54"
|
||||
esbuild-windows-64 "0.14.54"
|
||||
esbuild-windows-arm64 "0.14.54"
|
||||
|
||||
fill-range@^7.0.1:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz"
|
||||
integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
|
||||
dependencies:
|
||||
to-regex-range "^5.0.1"
|
||||
|
||||
function-bind@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
|
||||
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
|
||||
|
||||
glob-parent@~5.1.2:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
|
||||
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
|
||||
dependencies:
|
||||
is-glob "^4.0.1"
|
||||
|
||||
has@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.npmjs.org/has/-/has-1.0.3.tgz"
|
||||
integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
|
||||
dependencies:
|
||||
function-bind "^1.1.1"
|
||||
|
||||
immutable@^4.0.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz"
|
||||
integrity sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==
|
||||
|
||||
is-binary-path@~2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz"
|
||||
integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
|
||||
dependencies:
|
||||
binary-extensions "^2.0.0"
|
||||
|
||||
is-core-module@^2.9.0:
|
||||
version "2.11.0"
|
||||
resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz"
|
||||
integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==
|
||||
dependencies:
|
||||
has "^1.0.3"
|
||||
|
||||
is-extglob@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz"
|
||||
integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
|
||||
|
||||
is-glob@^4.0.1, is-glob@~4.0.1:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
|
||||
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
|
||||
dependencies:
|
||||
is-extglob "^2.1.1"
|
||||
|
||||
is-number@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz"
|
||||
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
|
||||
|
||||
"keycharm@^0.2.0 || ^0.3.0 || ^0.4.0", keycharm@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.npmjs.org/keycharm/-/keycharm-0.4.0.tgz"
|
||||
integrity sha512-TyQTtsabOVv3MeOpR92sIKk/br9wxS+zGj4BG7CR8YbK4jM3tyIBaF0zhzeBUMx36/Q/iQLOKKOT+3jOQtemRQ==
|
||||
|
||||
normalize-path@^3.0.0, normalize-path@~3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz"
|
||||
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
|
||||
|
||||
path-parse@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz"
|
||||
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
|
||||
|
||||
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.0:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
|
||||
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
||||
|
||||
readdirp@~3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz"
|
||||
integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
|
||||
dependencies:
|
||||
picomatch "^2.2.1"
|
||||
|
||||
resolve@^1.20.0:
|
||||
version "1.22.1"
|
||||
resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz"
|
||||
integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
|
||||
dependencies:
|
||||
is-core-module "^2.9.0"
|
||||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
sass@^1.45.0:
|
||||
version "1.56.2"
|
||||
resolved "https://registry.npmjs.org/sass/-/sass-1.56.2.tgz"
|
||||
integrity sha512-ciEJhnyCRwzlBCB+h5cCPM6ie/6f8HrhZMQOf5vlU60Y1bI1rx5Zb0vlDZvaycHsg/MqFfF1Eq2eokAa32iw8w==
|
||||
dependencies:
|
||||
chokidar ">=3.0.0 <4.0.0"
|
||||
immutable "^4.0.0"
|
||||
source-map-js ">=0.6.2 <2.0.0"
|
||||
|
||||
"source-map-js@>=0.6.2 <2.0.0":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz"
|
||||
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
|
||||
|
||||
supports-preserve-symlinks-flag@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
|
||||
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
||||
|
||||
timsort@^0.3.0:
|
||||
version "0.3.0"
|
||||
resolved "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz"
|
||||
integrity sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A==
|
||||
|
||||
to-regex-range@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz"
|
||||
integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
|
||||
dependencies:
|
||||
is-number "^7.0.0"
|
||||
|
||||
"uuid@^3.4.0 || ^7.0.0 || ^8.0.0", "uuid@^7.0.0 || ^8.0.0", uuid@^8.0.0:
|
||||
version "8.3.2"
|
||||
resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz"
|
||||
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
|
||||
|
||||
vis-data@^7.0.0, vis-data@^7.1.4:
|
||||
version "7.1.4"
|
||||
resolved "https://registry.npmjs.org/vis-data/-/vis-data-7.1.4.tgz"
|
||||
integrity sha512-usy+ePX1XnArNvJ5BavQod7YRuGQE1pjFl+pu7IS6rCom2EBoG0o1ZzCqf3l5US6MW51kYkLR+efxRbnjxNl7w==
|
||||
|
||||
vis-network@^9.1.2:
|
||||
version "9.1.2"
|
||||
resolved "https://registry.npmjs.org/vis-network/-/vis-network-9.1.2.tgz"
|
||||
integrity sha512-BdapguKg7sk3NvdZaDsM7T6rNhOBFz0/F4ZScxctK4klRzQPLQPTEcmbioXaZhMkkgWymzBR3lFCxL1q+eYyAw==
|
||||
|
||||
"vis-util@^4.0.0 || ^5.0.0", vis-util@^5.0.1, vis-util@^5.0.3:
|
||||
version "5.0.3"
|
||||
resolved "https://registry.npmjs.org/vis-util/-/vis-util-5.0.3.tgz"
|
||||
integrity sha512-Wf9STUcFrDzK4/Zr7B6epW2Kvm3ORNWF+WiwEz2dpf5RdWkLUXFSbLcuB88n1W6tCdFwVN+v3V4/Xmn9PeL39g==
|
||||
@@ -0,0 +1,71 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from netbox.tables import NetBoxTable, ChoiceFieldColumn
|
||||
from netbox_topology_views.models import CoordinateGroup, Coordinate, CircuitCoordinate, PowerPanelCoordinate, PowerFeedCoordinate
|
||||
|
||||
class CoordinateGroupListTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
devices = tables.Column()
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = CoordinateGroup
|
||||
fields = ('pk', 'id', 'name', 'description', 'devices')
|
||||
default_columns = ('name', 'description', 'devices')
|
||||
|
||||
class CircuitCoordinateListTable(NetBoxTable):
|
||||
group = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
|
||||
device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = CircuitCoordinate
|
||||
fields = ('pk', 'id', 'group', 'device', 'x', 'y')
|
||||
default_columns = ('id', 'group', 'device', 'x', 'y')
|
||||
|
||||
class PowerPanelCoordinateListTable(NetBoxTable):
|
||||
group = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
|
||||
device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = PowerPanelCoordinate
|
||||
fields = ('pk', 'id', 'group', 'device', 'x', 'y')
|
||||
default_columns = ('id', 'group', 'device', 'x', 'y')
|
||||
|
||||
class PowerFeedCoordinateListTable(NetBoxTable):
|
||||
group = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
|
||||
device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = PowerFeedCoordinate
|
||||
fields = ('pk', 'id', 'group', 'device', 'x', 'y')
|
||||
default_columns = ('id', 'group', 'device', 'x', 'y')
|
||||
|
||||
class CoordinateListTable(NetBoxTable):
|
||||
group = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
|
||||
device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Coordinate
|
||||
fields = ('pk', 'id', 'group', 'device', 'x', 'y')
|
||||
default_columns = ('id', 'group', 'device', 'x', 'y')
|
||||
@@ -1,15 +0,0 @@
|
||||
|
||||
from extras.plugins import PluginTemplateExtension
|
||||
from django.conf import settings
|
||||
from packaging import version
|
||||
|
||||
NETBOX_CURRENT_VERSION = version.parse(settings.VERSION)
|
||||
|
||||
|
||||
class SiteButtons(PluginTemplateExtension):
|
||||
model = 'dcim.site'
|
||||
def buttons(self):
|
||||
return self.render('netbox_topology_views/site_button.html')
|
||||
|
||||
|
||||
template_extensions = [SiteButtons]
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
|
||||
{% block title %}Topology Views Coordinates{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Coordinates
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Group</th>
|
||||
<td>{{ object.group|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Circuit</th>
|
||||
<td>{{ object.device|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">X-Coordinate</th>
|
||||
<td>{{ object.x }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Y-Coordinate</th>
|
||||
<td>{{ object.y }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
|
||||
{% block title %}Add new Circuit Coordinates{% endblock title %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
|
||||
{% block title %}Edit Circuit Coordinates{% endblock title %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends 'generic/object_list.html' %}
|
||||
|
||||
{% block title %}Circuit Coordinates{% endblock title %}
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
|
||||
{% block title %}Topology Views Coordinates{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Coordinates
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Group</th>
|
||||
<td>{{ object.group|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Device</th>
|
||||
<td>{{ object.device|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">X-Coordinate</th>
|
||||
<td>{{ object.x }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Y-Coordinate</th>
|
||||
<td>{{ object.y }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
|
||||
{% block title %}Add new Device Coordinates{% endblock title %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
|
||||
{% block title %}Edit Device Coordinates{% endblock title %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends 'generic/object_list.html' %}
|
||||
|
||||
{% block title %}Device Coordinates{% endblock title %}
|
||||
@@ -0,0 +1,69 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block title %}Topology Views Coordinate Group{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Coordinate Group
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Name</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Description</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Circuit Coordinates</h5>
|
||||
<div class="card-body table-responsive">
|
||||
{% render_table circuitcoordinates_table %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Power Panel Coordinates</h5>
|
||||
<div class="card-body table-responsive">
|
||||
{% render_table powerpanelcoordinates_table %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Power Feed Coordinates</h5>
|
||||
<div class="card-body table-responsive">
|
||||
{% render_table powerfeedcoordinates_table %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Device Coordinates</h5>
|
||||
<div class="card-body table-responsive">
|
||||
{% render_table coordinates_table %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
|
||||
{% block title %}Add a new Coordinate Group{% endblock title %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
|
||||
{% block title %}Edit Coordinate Group{% endblock title %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends 'generic/object_list.html' %}
|
||||
|
||||
{% block title %}Coordinate Groups{% endblock title %}
|
||||
@@ -1,39 +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="btnDownloadImage" class="btn btn-sm btn-info" href="#">
|
||||
<i class="mdi mdi-download"></i>
|
||||
Download
|
||||
</a>
|
||||
<a class="btn btn-sm btn-danger" data-bs-dismiss="modal">
|
||||
<i class="mdi mdi-close-thick"></i>
|
||||
Close
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body" >
|
||||
<div style="max-width: 900px; height: 500px;">
|
||||
<div id="visgraph" style="width: 100%; height: 100%;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style type="text/css">
|
||||
.modal-dialog {
|
||||
max-width: 902px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
const brokenImage = '{{ broken_image }}';
|
||||
const topologyData = {{ topology_data | safe }};
|
||||
</script>
|
||||
<script src="{% static 'netbox_topology_views/js/app.js' %}?ver={{ epoch }} " defer></script>
|
||||
@@ -10,7 +10,7 @@
|
||||
<link rel="stylesheet" href="{% static 'netbox_topology_views/css/app.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content-wrapper %}
|
||||
{% block content %}
|
||||
<form id="images" class="container py-4">
|
||||
<div class="d-flex flex-wrap justify-content-center gap-3 mb-2">
|
||||
{% for role in roles %}
|
||||
@@ -39,8 +39,11 @@
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
</form>
|
||||
{% include 'netbox_topology_views/toasts.html' %}
|
||||
{% endblock content-wrapper %}
|
||||
{% endblock content %}
|
||||
|
||||
{% block javascript %}
|
||||
<script src="{% static 'netbox_topology_views/js/images.js' %}" defer></script>
|
||||
{% endblock javascript %}
|
||||
<script type="text/javascript">
|
||||
const basePath = '{{ basepath }}';
|
||||
</script>
|
||||
<script src="{% static 'netbox_topology_views/js/images.js' %}" defer></script>
|
||||
{% endblock javascript %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% extends 'base/layout.html' %}
|
||||
{% extends 'generic/_base.html' %}
|
||||
{% load buttons %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load static %}
|
||||
@@ -16,16 +16,20 @@
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
{% block extra_controls %}{% endblock %}
|
||||
<a id="btnDownloadImage" class="btn btn-sm btn-info" href="#">
|
||||
<a id="btnDownloadXml" class="btn btn-info" href="#">
|
||||
<i class="mdi mdi-download"></i>
|
||||
Download
|
||||
Download XML
|
||||
</a>
|
||||
<a id="btnDownloadImage" class="btn btn-info" href="#">
|
||||
<i class="mdi mdi-download"></i>
|
||||
Download PNG
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock controls %}
|
||||
|
||||
{% block tabs %}
|
||||
<ul class="nav nav-tabs px-3">
|
||||
<ul class="nav nav-tabs" id="tabswrapper">
|
||||
{% block tab_items %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="network-tab" data-bs-toggle="tab" data-bs-target="#networks" type="button" role="tab" aria-controls="filters-form" aria-selected="true">
|
||||
@@ -44,18 +48,26 @@
|
||||
</ul>
|
||||
{% endblock tabs %}
|
||||
|
||||
{% block content-wrapper %}
|
||||
{% block content %}
|
||||
{% with config=settings.PLUGINS_CONFIG.netbox_topology_views %}
|
||||
<div class="tab-content">
|
||||
<div class="tab-content" id="tabcontentwrapper">
|
||||
|
||||
{# Applied filters #}
|
||||
{% if filter_form %}
|
||||
{% applied_filters model filter_form request.GET %}
|
||||
<hr style="margin-top: 0px;">
|
||||
<div id="filterwrapper">
|
||||
{% applied_filters model filter_form request.GET %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="tab-pane show active" id="networks" role="tabpanel" aria-labelledby="network-tab">
|
||||
<div class="panel-body">
|
||||
{% if filter_required %}
|
||||
<div class="bg-warning text-white p-2">Nothing to display! Set filters and hit search button or set "Draw Default Layout" in your individual options.</div>
|
||||
{% endif %}
|
||||
{% if empty_result %}
|
||||
<div class="bg-warning text-white p-2">Empty result set! Please review your filter settings.</div>
|
||||
<div class="bg-info text-white p-2">Hint: Some options may produce an empty result. Use at least "Show Unconnected/Cables/Logical Connections".</div>
|
||||
{% endif %}
|
||||
<div id="visgraph"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,12 +80,42 @@
|
||||
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endblock content-wrapper %}
|
||||
{% endblock content %}
|
||||
|
||||
{% block javascript %}
|
||||
<script type="text/javascript">
|
||||
const brokenImage = '{{ broken_image }}';
|
||||
const topologyData = {{ topology_data | safe }};
|
||||
</script>
|
||||
const basePath = '{{ basepath }}';
|
||||
|
||||
window.addEventListener("resize", resizeCanvas);
|
||||
resizeCanvas();
|
||||
|
||||
function resizeCanvas() {
|
||||
heightToSubtract = getElementHeight('.page-header') +
|
||||
getElementHeight('#tabswrapper') +
|
||||
getElementHeight('#filterwrapper') +
|
||||
getElementHeight('.footer') +
|
||||
getElementPadding('#tabcontentwrapper');
|
||||
|
||||
document.getElementById("visgraph").style.height = "calc(100vh - " + heightToSubtract + "px)";
|
||||
}
|
||||
|
||||
function getElementPadding(element) {
|
||||
el = document.querySelector(element);
|
||||
els = getComputedStyle(el);
|
||||
padding = parseInt(els.paddingTop) + parseInt(els.paddingBottom);
|
||||
|
||||
return padding;
|
||||
}
|
||||
|
||||
function getElementHeight(element) {
|
||||
el = document.querySelector(element);
|
||||
els = getComputedStyle(el);
|
||||
height = el.offsetHeight + parseInt(els.marginTop) + parseInt(els.marginBottom);
|
||||
|
||||
return height;
|
||||
}
|
||||
</script>
|
||||
<script src="{% static 'netbox_topology_views/js/app.js' %}" defer></script>
|
||||
{% endblock javascript %}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<a role="button" class="btn btn-primary" title="Topology" href="#" hx-get="/plugins/netbox_topology_views/topology/?location_id={{ object.id }}&show_cables=True&show_unconnected=True" hx-target="#htmx-modal-content" data-bs-toggle="modal" data-bs-target="#htmx-modal">
|
||||
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
|
||||
Topology
|
||||
</a>
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
|
||||
{% block title %}Topology Views Coordinates{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Coordinates
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Group</th>
|
||||
<td>{{ object.group|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Power Feed</th>
|
||||
<td>{{ object.device|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">X-Coordinate</th>
|
||||
<td>{{ object.x }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Y-Coordinate</th>
|
||||
<td>{{ object.y }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
|
||||
{% block title %}Add new Power Feed Coordinates{% endblock title %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
|
||||
{% block title %}Edit Power Feed Coordinates{% endblock title %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends 'generic/object_list.html' %}
|
||||
|
||||
{% block title %}Power Feed Coordinates{% endblock title %}
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
|
||||
{% block title %}Topology Views Coordinates{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Coordinates
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Group</th>
|
||||
<td>{{ object.group|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Power Panel</th>
|
||||
<td>{{ object.device|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">X-Coordinate</th>
|
||||
<td>{{ object.x }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Y-Coordinate</th>
|
||||
<td>{{ object.y }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
|
||||
{% block title %}Add new Power Panel Coordinates{% endblock title %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
|
||||
{% block title %}Edit Power Panel Coordinates{% endblock title %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends 'generic/object_list.html' %}
|
||||
|
||||
{% block title %}Power Panel Coordinates{% endblock title %}
|
||||
@@ -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 %}
|
||||
@@ -1,4 +0,0 @@
|
||||
<a role="button" class="btn btn-sm btn-primary" title="Topology" href="#" hx-get="/plugins/netbox_topology_views/topology/?site_id={{ object.id }}&show_cables=on" 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,13 +1,59 @@
|
||||
from django.urls import path
|
||||
from django.views.generic.base import RedirectView
|
||||
|
||||
from . import views
|
||||
from netbox.views.generic import ObjectChangeLogView
|
||||
from . import models, views
|
||||
|
||||
# Define a list of URL patterns to be imported by NetBox. Each pattern maps a URL to
|
||||
# a specific view so that it can be accessed by users.
|
||||
urlpatterns = (
|
||||
path("", 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"),
|
||||
|
||||
# Coordinate Group
|
||||
path("coordinate-groups/", views.CoordinateGroupListView.as_view(), name="coordinategroup_list"),
|
||||
path("coordinate-groups/add/", views.CoordinateGroupAddView.as_view(), name="coordinategroup_add"),
|
||||
path('coordinate-groups/import/', views.CoordinateGroupBulkImportView.as_view(), name='coordinategroup_import'),
|
||||
path("coordinate-groups/<int:pk>/", views.CoordinateGroupView.as_view(), name="coordinategroup"),
|
||||
path("coordinate-groups/<int:pk>/edit/", views.CoordinateGroupEditView.as_view(), name="coordinategroup_edit"),
|
||||
path("coordinate-groups/<int:pk>/delete/", views.CoordinateGroupDeleteView.as_view(), name="coordinategroup_delete"),
|
||||
path("coordinate-groups/<int:pk>/changelog/", ObjectChangeLogView.as_view(), name="coordinategroup_changelog", kwargs={'model': models.CoordinateGroup}),
|
||||
|
||||
# Circuit Coordinate
|
||||
path("circuitcoordinate/", views.CircuitCoordinateListView.as_view(), name="circuitcoordinate_list"),
|
||||
path("circuitcoordinate/add/", views.CircuitCoordinateAddView.as_view(), name="circuitcoordinate_add"),
|
||||
path('circuitcoordinate/import/', views.CircuitCoordinateBulkImportView.as_view(), name='circuitcoordinate_import'),
|
||||
path("circuitcoordinate/<int:pk>/", views.CircuitCoordinateView.as_view(), name="circuitcoordinate"),
|
||||
path("circuitcoordinate/<int:pk>/edit/", views.CircuitCoordinateEditView.as_view(), name="circuitcoordinate_edit"),
|
||||
path("circuitcoordinate/<int:pk>/delete/", views.CircuitCoordinateDeleteView.as_view(), name="circuitcoordinate_delete"),
|
||||
path("circuitcoordinate/<int:pk>/changelog/", ObjectChangeLogView.as_view(), name="circuitcoordinate_changelog", kwargs={'model': models.CircuitCoordinate}),
|
||||
|
||||
# Power Panel Coordinate
|
||||
path("powerpanelcoordinate/", views.PowerPanelCoordinateListView.as_view(), name="powerpanelcoordinate_list"),
|
||||
path("powerpanelcoordinate/add/", views.PowerPanelCoordinateAddView.as_view(), name="powerpanelcoordinate_add"),
|
||||
path('powerpanelcoordinate/import/', views.PowerPanelCoordinateBulkImportView.as_view(), name='powerpanelcoordinate_import'),
|
||||
path("powerpanelcoordinate/<int:pk>/", views.PowerPanelCoordinateView.as_view(), name="powerpanelcoordinate"),
|
||||
path("powerpanelcoordinate/<int:pk>/edit/", views.PowerPanelCoordinateEditView.as_view(), name="powerpanelcoordinate_edit"),
|
||||
path("powerpanelcoordinate/<int:pk>/delete/", views.PowerPanelCoordinateDeleteView.as_view(), name="powerpanelcoordinate_delete"),
|
||||
path("powerpanelcoordinate/<int:pk>/changelog/", ObjectChangeLogView.as_view(), name="powerpanelcoordinate_changelog", kwargs={'model': models.PowerPanelCoordinate}),
|
||||
|
||||
# Power Feed Coordinate
|
||||
path("powerfeedcoordinate/", views.PowerFeedCoordinateListView.as_view(), name="powerfeedcoordinate_list"),
|
||||
path("powerfeedcoordinate/add/", views.PowerFeedCoordinateAddView.as_view(), name="powerfeedcoordinate_add"),
|
||||
path('powerfeedcoordinate/import/', views.PowerFeedCoordinateBulkImportView.as_view(), name='powerfeedcoordinate_import'),
|
||||
path("powerfeedcoordinate/<int:pk>/", views.PowerFeedCoordinateView.as_view(), name="powerfeedcoordinate"),
|
||||
path("powerfeedcoordinate/<int:pk>/edit/", views.PowerFeedCoordinateEditView.as_view(), name="powerfeedcoordinate_edit"),
|
||||
path("powerfeedcoordinate/<int:pk>/delete/", views.PowerFeedCoordinateDeleteView.as_view(), name="powerfeedcoordinate_delete"),
|
||||
path("powerfeedcoordinate/<int:pk>/changelog/", ObjectChangeLogView.as_view(), name="powerfeedcoordinate_changelog", kwargs={'model': models.PowerFeedCoordinate}),
|
||||
|
||||
# Coordinate
|
||||
path("coordinate/", views.CoordinateListView.as_view(), name="coordinate_list"),
|
||||
path("coordinate/add/", views.CoordinateAddView.as_view(), name="coordinate_add"),
|
||||
path('coordinate/import/', views.CoordinateBulkImportView.as_view(), name='coordinate_import'),
|
||||
path("coordinate/<int:pk>/", views.CoordinateView.as_view(), name="coordinate"),
|
||||
path("coordinate/<int:pk>/edit/", views.CoordinateEditView.as_view(), name="coordinate_edit"),
|
||||
path("coordinate/<int:pk>/delete/", views.CoordinateDeleteView.as_view(), name="coordinate_delete"),
|
||||
path("coordinate/<int:pk>/changelog/", ObjectChangeLogView.as_view(), name="coordinate_changelog", kwargs={'model': models.Coordinate}),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Type
|
||||
import base64
|
||||
import xml.dom.minidom
|
||||
|
||||
import sys
|
||||
|
||||
@@ -9,6 +11,7 @@ from django.conf import settings
|
||||
from django.db.models import Model
|
||||
from django.templatetags.static import static
|
||||
from django.utils.text import camel_case_to_spaces, re_camel_case
|
||||
from django.contrib import messages
|
||||
|
||||
IMAGE_DIR = Path(settings.STATIC_ROOT) / "netbox_topology_views/img"
|
||||
if sys.version_info >= (3,9,0):
|
||||
@@ -26,18 +29,17 @@ else:
|
||||
|
||||
|
||||
def image_static_url(path: Path) -> str:
|
||||
return settings.BASE_PATH + static(
|
||||
return static(
|
||||
f"/{path.relative_to(Path(settings.STATIC_ROOT))}"
|
||||
)
|
||||
|
||||
|
||||
def get_image_from_url(url: str) -> str:
|
||||
url_path = settings.BASE_PATH + settings.STATIC_URL
|
||||
if sys.version_info >= (3,9,0):
|
||||
return url.removeprefix(url_path)
|
||||
return url.removeprefix(settings.STATIC_URL)
|
||||
else:
|
||||
if url.startswith(url_path):
|
||||
url_new = url[len(url_path):]
|
||||
if url.startswith(settings.STATIC_URL):
|
||||
url_new = url[len(settings.STATIC_URL):]
|
||||
return url_new
|
||||
else:
|
||||
return url
|
||||
@@ -103,3 +105,263 @@ def get_model_role(model: Type[Model]) -> Role:
|
||||
slug=get_model_slug(model),
|
||||
name=re_camel_case.sub(r" \1", model.__name__),
|
||||
)
|
||||
|
||||
def get_query_settings(request):
|
||||
filter_id = ''
|
||||
if "filter_id" in request.GET:
|
||||
filter_id = request.GET["filter_id"]
|
||||
|
||||
save_coords = False
|
||||
if "save_coords" in request.GET:
|
||||
if request.GET["save_coords"] == "True":
|
||||
save_coords = True
|
||||
# General options overrides
|
||||
if save_coords == True and settings.PLUGINS_CONFIG["netbox_topology_views"]["allow_coordinates_saving"] == False:
|
||||
save_coords = False
|
||||
messages.warning(request, "Coordinate saving not allowed. Setting has been overridden")
|
||||
elif settings.PLUGINS_CONFIG["netbox_topology_views"]["always_save_coordinates"] == True:
|
||||
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":
|
||||
show_unconnected = True
|
||||
|
||||
show_power = False
|
||||
if "show_power" in request.GET:
|
||||
if request.GET["show_power"] == "True":
|
||||
show_power = True
|
||||
|
||||
show_circuit = False
|
||||
if "show_circuit" in request.GET:
|
||||
if request.GET["show_circuit"] == "True":
|
||||
show_circuit = True
|
||||
|
||||
show_logical_connections = False
|
||||
if "show_logical_connections" in request.GET:
|
||||
if request.GET["show_logical_connections"] == "True" :
|
||||
show_logical_connections = True
|
||||
|
||||
show_single_cable_logical_conns = False
|
||||
if "show_single_cable_logical_conns" in request.GET:
|
||||
if request.GET["show_single_cable_logical_conns"] == "True" :
|
||||
show_single_cable_logical_conns = True
|
||||
|
||||
show_cables = False
|
||||
if "show_cables" in request.GET:
|
||||
if request.GET["show_cables"] == "True" :
|
||||
show_cables = True
|
||||
|
||||
show_wireless = False
|
||||
if "show_wireless" in request.GET:
|
||||
if request.GET["show_wireless"] == "True" :
|
||||
show_wireless = True
|
||||
|
||||
group_sites = False
|
||||
if "group_sites" in request.GET:
|
||||
if request.GET["group_sites"] == "True" :
|
||||
group_sites = True
|
||||
|
||||
group_locations = False
|
||||
if "group_locations" in request.GET:
|
||||
if request.GET["group_locations"] == "True" :
|
||||
group_locations = True
|
||||
|
||||
group_racks = False
|
||||
if "group_racks" in request.GET:
|
||||
if request.GET["group_racks"] == "True" :
|
||||
group_racks = True
|
||||
|
||||
group_virtualchassis = False
|
||||
if "group_virtualchassis" in request.GET:
|
||||
if request.GET["group_virtualchassis"] == "True" :
|
||||
group_virtualchassis = True
|
||||
|
||||
group = False
|
||||
if "group" in request.GET:
|
||||
group = request.GET["group"]
|
||||
|
||||
show_neighbors = False
|
||||
if "show_neighbors" in request.GET:
|
||||
if request.GET["show_neighbors"] == "True" :
|
||||
show_neighbors = True
|
||||
|
||||
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]
|
||||
power = [5, 5, 3, 3]
|
||||
logical = [1, 10, 1, 10]
|
||||
|
||||
|
||||
def export_data_to_xml(data: dict):
|
||||
if data is None:
|
||||
return ''
|
||||
|
||||
# static xml header
|
||||
doc = xml.dom.minidom.Document()
|
||||
mxfile = doc.createElement('mxfile')
|
||||
mxfile.setAttribute('host', 'app.diagrams.net')
|
||||
mxfile.setAttribute('type', 'device')
|
||||
diagram = doc.createElement('diagram')
|
||||
diagram.setAttribute('name', 'topology')
|
||||
diagram.setAttribute('id', 'someid')
|
||||
|
||||
doc.appendChild(mxfile)
|
||||
mxfile.appendChild(diagram)
|
||||
|
||||
# mxGraphModel
|
||||
mxgraphmodel = doc.createElement('mxGraphModel')
|
||||
mxgraphmodel.setAttribute('dx', '0')
|
||||
mxgraphmodel.setAttribute('dy', '0')
|
||||
mxgraphmodel.setAttribute('grid', '0')
|
||||
mxgraphmodel.setAttribute('guides', '1')
|
||||
mxgraphmodel.setAttribute('tooltips', '1')
|
||||
mxgraphmodel.setAttribute('connect', '1')
|
||||
mxgraphmodel.setAttribute('arrows', '1')
|
||||
mxgraphmodel.setAttribute('fold', '1')
|
||||
mxgraphmodel.setAttribute('page', '1')
|
||||
mxgraphmodel.setAttribute('pageScale', '1')
|
||||
mxgraphmodel.setAttribute('pageWidth', '827')
|
||||
mxgraphmodel.setAttribute('pageHeight', '1169')
|
||||
mxgraphmodel.setAttribute('background', 'none')
|
||||
mxgraphmodel.setAttribute('math', '0')
|
||||
mxgraphmodel.setAttribute('shadow', '0')
|
||||
|
||||
diagram.appendChild(mxgraphmodel)
|
||||
|
||||
# root
|
||||
root = doc.createElement('root')
|
||||
|
||||
mxgraphmodel.appendChild(root)
|
||||
|
||||
# mxCells
|
||||
# header
|
||||
mxcell = doc.createElement('mxCell')
|
||||
mxcell.setAttribute('id', '0')
|
||||
root.appendChild(mxcell)
|
||||
mxcell = doc.createElement('mxCell')
|
||||
mxcell.setAttribute('id', '1')
|
||||
mxcell.setAttribute('parent', '0')
|
||||
root.appendChild(mxcell)
|
||||
|
||||
# edges
|
||||
for edge in data['edges']:
|
||||
dashes = ''
|
||||
if 'dashes' in edge:
|
||||
if edge['dashes'] == LinePattern().wireless:
|
||||
dashes = 'dashed=1;dashPattern=3 6;'
|
||||
if edge['dashes'] == LinePattern().power:
|
||||
dashes = 'dashed=1;dashPattern=6 6;'
|
||||
if edge['dashes'] == LinePattern().logical:
|
||||
dashes = 'dashed=1;dashPattern=1 4;strokeWidth=2;'
|
||||
|
||||
mxcell = doc.createElement('mxCell')
|
||||
mxcell.setAttribute('id', 'edge_' + str(edge['id']))
|
||||
mxcell.setAttribute('style', 'rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=' + edge['color'] + ';' + dashes)
|
||||
mxcell.setAttribute('edge', '1')
|
||||
mxcell.setAttribute('parent', '1')
|
||||
mxcell.setAttribute('source', 'node_' + str(edge['from']))
|
||||
mxcell.setAttribute('target', 'node_' + str(edge['to']))
|
||||
|
||||
root.appendChild(mxcell)
|
||||
|
||||
mxgeometry = doc.createElement('mxGeometry')
|
||||
mxgeometry.setAttribute('relative', '1')
|
||||
mxgeometry.setAttribute('as', 'geometry')
|
||||
|
||||
mxcell.appendChild(mxgeometry)
|
||||
|
||||
# nodes
|
||||
noPositionX = 0
|
||||
noPositionY = 1000
|
||||
for node in data['nodes']:
|
||||
with open(settings.STATIC_ROOT + '/' + get_image_from_url(node['image']), "rb") as img:
|
||||
svg = base64.b64encode(img.read()).decode('utf-8')
|
||||
mxcell = doc.createElement('mxCell')
|
||||
mxcell.setAttribute('id', 'node_' + str(node['id']))
|
||||
mxcell.setAttribute('value', str(node['label']))
|
||||
mxcell.setAttribute('style', 'shape=image;verticalLabelPosition=bottom;verticalAlign=top;imageAspect=0;aspect=fixed;image=data:image/svg+xml,' + svg + ';perimeter=elippsePerimeter;fontSize=10;strokeWidth=1;fontColor=#000000;')
|
||||
mxcell.setAttribute('vertex', '1')
|
||||
mxcell.setAttribute('parent', '1')
|
||||
|
||||
root.appendChild(mxcell)
|
||||
|
||||
mxgeometry = doc.createElement('mxGeometry')
|
||||
# Check if x and y values are stored in the database and set pseudo
|
||||
# coordinates if not. Otherwise icons will not be exported / KeyError is raised
|
||||
if 'x' in node:
|
||||
mxgeometry.setAttribute('x', str(node['x']))
|
||||
else:
|
||||
# Set next pseudo coordinates
|
||||
if noPositionX > 2500:
|
||||
noPositionX = 100
|
||||
noPositionY = noPositionY + 100
|
||||
else:
|
||||
noPositionX = noPositionX + 100
|
||||
|
||||
mxgeometry.setAttribute('x', str(noPositionX))
|
||||
if 'y' in node:
|
||||
mxgeometry.setAttribute('y', str(node['y']))
|
||||
else:
|
||||
mxgeometry.setAttribute('y', str(noPositionY))
|
||||
|
||||
mxgeometry.setAttribute('width', '50')
|
||||
mxgeometry.setAttribute('height', '50')
|
||||
mxgeometry.setAttribute('as', 'geometry')
|
||||
|
||||
mxcell.appendChild(mxgeometry)
|
||||
|
||||
# Place a warning if one or more icons are misplaced because of missing coordinates
|
||||
if noPositionX > 0:
|
||||
mxcell = doc.createElement('mxCell')
|
||||
mxcell.setAttribute('id', 'noPositionWarning')
|
||||
mxcell.setAttribute('value', 'One or more icons are misplaced. This happens if no coordinates are stored for a device. Turn on coordinates saving and make sure to drag all icons in the topology to a specific position before exporting to XML.')
|
||||
mxcell.setAttribute('style', 'text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontColor=#FF0000;fontStyle=0;fontSize=35;labelBackgroundColor=#FFFF99;labelBorderColor=none;strokeWidth=20;')
|
||||
mxcell.setAttribute('vertex', '1')
|
||||
mxcell.setAttribute('parent', '1')
|
||||
|
||||
root.appendChild(mxcell)
|
||||
|
||||
mxgeometry = doc.createElement('mxGeometry')
|
||||
mxgeometry.setAttribute('x', '0')
|
||||
mxgeometry.setAttribute('y', '0')
|
||||
mxgeometry.setAttribute('width', '870')
|
||||
mxgeometry.setAttribute('height', '100')
|
||||
mxgeometry.setAttribute('as', 'geometry')
|
||||
|
||||
mxcell.appendChild(mxgeometry)
|
||||
|
||||
# debug output
|
||||
# print(doc.toprettyxml())
|
||||
|
||||
return doc.toxml(encoding='UTF-8')
|
||||
|
||||
@@ -7,8 +7,8 @@ long_description = readme.read_text()
|
||||
|
||||
setup(
|
||||
name="netbox-topology-views",
|
||||
version="3.4.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",
|
||||
|
||||