Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffe594a5f9 | ||
|
|
4a12037ac4 | ||
|
|
f08df4fd52 | ||
|
|
2e43166949 | ||
|
|
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 |
@@ -1,220 +1,230 @@
|
||||
# Netbox Topology Views Plugin
|
||||
# MRB NetBox Topology Views
|
||||
|
||||
 
|
||||
A customised fork of **NetBox Topology Views** with extended rack views,
|
||||
interactive cable tracing and additional network icons.
|
||||
|
||||
Create topology views/maps from your devices in NetBox.
|
||||
The connections are based on the cables you created in NetBox.
|
||||
Support to filter on name, site, tag and device role.
|
||||
Options to export to xml (for draw.io/diagrams.net) or png.
|
||||
The plugin builds topologies from the devices, ports and cables maintained in
|
||||
NetBox. In addition to the classic topology, this fork provides a complete view
|
||||
of one or more racks including front, rear and cabling.
|
||||
|
||||
## Preview
|
||||
| | |
|
||||
|---|---|
|
||||
| **Plugin name** | `netbox_topology_views` |
|
||||
| **Package** | `netbox-topology-views` |
|
||||
| **NetBox** | `4.7.x` |
|
||||
| **Python** | see `setup.py` |
|
||||
| **Repository** | <https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views> |
|
||||
|
||||

|
||||

|
||||
## Features
|
||||
|
||||
## Install
|
||||
- Show one or more racks together
|
||||
- Render devices at their exact rack unit position
|
||||
- View front and rear of a rack separately
|
||||
- Ascending/descending U numbering and custom starting unit are respected automatically
|
||||
- Detect cross-rack and external cable connections
|
||||
- Freely move devices in the cable topology
|
||||
- Sort devices in the cable topology by rack position within each rack
|
||||
- Browser full-screen mode for the cable topology
|
||||
- Zoom the cable topology between 50 and 200 % and scroll in full-screen
|
||||
- Render P2P wireless links and multi-access WLANs as purple dashed radio links
|
||||
- Optionally show/hide wireless links and power cables in the rack view's cable topology, visually distinct from network cables (purple dashed / orange dotted)
|
||||
- Store manual positions per rack selection in the browser
|
||||
- Highlight a device's connections in white on hover
|
||||
- Show all connected ports and peers of a device
|
||||
- Show a cable's terminations on hover
|
||||
- Open devices and cables in NetBox directly from the topology
|
||||
- Export single racks, all racks or the cable topology as SVG, PNG and draw.io — always with transparent background
|
||||
- Colours of rack view and export follow the active light or dark mode
|
||||
- Additional icons for network, leaf, spine and aggregation switches
|
||||
|
||||
**_NOTE:_** For Docker please see: [Docker install](https://github.com/netbox-community/netbox-docker/wiki/Using-Netbox-Plugins)
|
||||
### Changes compared to the original
|
||||
|
||||
**_NOTE:_** Add `RUN mkdir -p /opt/netbox/netbox/static/netbox_topology_views/img` to the Dockerfile-Plugins file to create the image folder.
|
||||
1. New menu category **More views**.
|
||||
2. A rack view with filters for site, location and multiple racks.
|
||||
3. Position-accurate rack elevations for front and rear.
|
||||
4. A cable table with A and B terminations.
|
||||
5. An interactive, movable cable topology below the racks.
|
||||
6. Hover highlighting and port-to-port tracing for devices and cables.
|
||||
7. Locally stored device positions and a reset function.
|
||||
8. An extended, scalable SVG icon set for switch roles.
|
||||
9. Transparent SVG, PNG and draw.io exports for rack and topology views.
|
||||
10. Adjustments for NetBox 4.6+, including the changed `Rack.units` property.
|
||||
|
||||
The plugin is available as a Python package and can be installed with pip.
|
||||

|
||||

|
||||
|
||||
First run `source /opt/netbox/venv/bin/activate` to enter the Python virtual environment.
|
||||
## Compatibility
|
||||
|
||||
- NetBox `4.7.x` (`min_version`/`max_version` in `netbox_topology_views/__init__.py`)
|
||||
- Based on NetBox Topology Views `4.5.1`
|
||||
|
||||
Since the upstream release officially targets NetBox 4.5.x, test updates to
|
||||
newer NetBox versions in a staging instance first.
|
||||
|
||||
## Installation
|
||||
|
||||
All paths assume a standard installation under `/opt/netbox`.
|
||||
|
||||
### 1. Install the package
|
||||
|
||||
Then run:
|
||||
```bash
|
||||
cd /opt/netbox/netbox
|
||||
pip3 install netbox-topology-views
|
||||
python3 manage.py migrate netbox_topology_views
|
||||
python3 manage.py collectstatic --no-input
|
||||
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
|
||||
"git+https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views.git@develop"
|
||||
```
|
||||
|
||||
To ensure NetBox Topology Views plugin is automatically re-installed during future upgrades, create a file named `local_requirements.txt` (if not already existing) in the NetBox root directory (alongside `requirements.txt`) and list the `netbox-topology-views` package:
|
||||
For reproducible production installs, replace `develop` with a release tag or a
|
||||
full commit ID.
|
||||
|
||||
```no-highlight
|
||||
echo netbox-topology-views >> /opt/netbox/local_requirements.txt
|
||||
### 2. Add the plugin to `local_requirements.txt`
|
||||
|
||||
This makes `upgrade.sh` reinstall the plugin automatically on every NetBox upgrade:
|
||||
|
||||
```bash
|
||||
grep -qxF "git+https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views.git@develop" /opt/netbox/local_requirements.txt \
|
||||
|| echo "git+https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views.git@develop" | sudo tee -a /opt/netbox/local_requirements.txt
|
||||
```
|
||||
|
||||
Once installed, the plugin needs to be enabled in your `configuration.py`:
|
||||
```no-highlight
|
||||
vim /opt/netbox/netbox/netbox/configuration.py
|
||||
```
|
||||
If the repository is private, the NetBox server needs a read-only deploy token
|
||||
or an SSH key. Do not store credentials in `local_requirements.txt`.
|
||||
|
||||
### 3. Enable the plugin
|
||||
|
||||
In `/opt/netbox/netbox/netbox/configuration.py`:
|
||||
|
||||
```python
|
||||
PLUGINS = ["netbox_topology_views"]
|
||||
```
|
||||
PLUGINS = [
|
||||
"netbox_topology_views",
|
||||
]
|
||||
|
||||
Finally, restart NetBox:
|
||||
```no-highlight
|
||||
systemctl restart netbox
|
||||
```
|
||||
|
||||
### Versions
|
||||
|
||||
> [!NOTE]
|
||||
> If the version of netbox is not listed here the plugin will most likely not work. Remove the plugin if you want to update netbox before this plugin has released an update for that netbox version.
|
||||
|
||||
| netbox version | netbox-topology-views version |
|
||||
| -------------- | ----------------------------- |
|
||||
| = 4.4.X | = v4.4.0 |
|
||||
| = 4.3.X | = v4.3.0 |
|
||||
| = 4.2.1 | = v4.2.1 |
|
||||
| = 4.2.0 | = v4.2.0 |
|
||||
| = 4.1.X | = v4.1.0 |
|
||||
| = 4.0.X | = v4.0.0 |
|
||||
| = 3.7.0 | = v3.9.0 |
|
||||
| = 3.6.4 | = v3.8.1 |
|
||||
| = 3.6.0 | = v3.7.0 |
|
||||
| = 3.5.0 | = v3.6.2 |
|
||||
|
||||
|
||||
### Update
|
||||
|
||||
Run `pip install netbox-topology-views --upgrade` in your venv.
|
||||
|
||||
Run `python3 manage.py migrate netbox_topology_views`
|
||||
|
||||
Run `python3 manage.py collectstatic --no-input`
|
||||
|
||||
Clear you browser cache.
|
||||
|
||||
## Configure
|
||||
|
||||
### Individual Options
|
||||
|
||||
All individual options can be assigned a default value per user directly in the plugin. The default value can be overridden on the filter page.
|
||||
|
||||

|
||||
|
||||
The remaining options must be configured in the `PLUGINS_CONFIG` section of your `netbox/configuration.py`.
|
||||
|
||||
Example:
|
||||
```
|
||||
PLUGINS_CONFIG = {
|
||||
'netbox_topology_views': {
|
||||
'static_image_directory': 'netbox_topology_views/img',
|
||||
'allow_coordinates_saving': True,
|
||||
'always_save_coordinates': True
|
||||
}
|
||||
"netbox_topology_views": {
|
||||
"static_image_directory": "netbox_topology_views/img",
|
||||
"allow_coordinates_saving": True,
|
||||
"always_save_coordinates": False,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Default value | Description |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| static_image_directory | netbox_topology_views/img | (str or pathlib.Path) Specifies the location that images will be loaded from by default. Must be within `STATIC_ROOT` |
|
||||
| allow_coordinates_saving | False | (bool) Set to true if you want to enable the ability to save the coordinates. |
|
||||
| always_save_coordinates | False | (bool) Set if you want to enable the option to save coordinates by default. Setting allow_coordinates_saving to true is mandatory. |
|
||||
If other plugins are already configured, add `netbox_topology_views` to the
|
||||
existing list and dictionary instead of replacing them.
|
||||
|
||||
### Custom field: coordinates
|
||||
### 4. Apply migrations, collect static files, restart
|
||||
|
||||
There is also support for custom fields.
|
||||
```bash
|
||||
cd /opt/netbox/netbox
|
||||
/opt/netbox/venv/bin/python manage.py migrate netbox_topology_views
|
||||
/opt/netbox/venv/bin/python manage.py collectstatic --no-input
|
||||
sudo systemctl restart netbox netbox-rq
|
||||
```
|
||||
|
||||
>**_Note:_** The custom field "coordinates" is deprecated and will be removed in the future. Please use Coordinate Groups instead.
|
||||
After changes to CSS or icons a hard reload in the browser (`Ctrl+F5`) may be
|
||||
necessary.
|
||||
|
||||
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.
|
||||
## Update
|
||||
|
||||
The coordinates are stored as: "X;Y".
|
||||
```bash
|
||||
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
|
||||
"git+https://git.mrblake.cc/MrBlake/mrb-netbox-topology-views.git@develop"
|
||||
|
||||
> 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.
|
||||
cd /opt/netbox/netbox
|
||||
/opt/netbox/venv/bin/python manage.py migrate netbox_topology_views
|
||||
/opt/netbox/venv/bin/python manage.py collectstatic --no-input
|
||||
sudo systemctl restart netbox netbox-rq
|
||||
```
|
||||
|
||||
### Convert custom field to Coordinate Groups
|
||||
`--force-reinstall` is required because the fork still uses the internal
|
||||
package version `4.5.2`.
|
||||
|
||||
Please note that values stored in the custom field "coordinates" are not being converted to Coordinate Groups automatically. A pragmatic way to do this conversion yourself is as follows:
|
||||
+ Navigate to "Topology" > "Filters".
|
||||
+ Select "Show Unconnected" and "Show Cables".
|
||||
+ Leave all other filter settings alone. We want all entries to be displayed!
|
||||
+ Click "Search" and wait for the results to be displayed
|
||||
+ Select all nodes. This can be done by holding down the Shift key and dragging a frame around all icons with the left mouse button.
|
||||
+ Drag the selection a tiny bit to the side. This causes all coordinates for all devices to be stored in the "default" group.
|
||||
> **_Hint_**: Don't wait too long after clicking an icon in order to drag. If you hold the mouse button for too long before dragging starts, the selection is reset._
|
||||
+ Storing the values might take some time, depending on the number of devices. Please be patient and check "Coordinates" in order to make sure that everthing has been stored.
|
||||
+ It is save to delete the custom field now.
|
||||
When NetBox itself is upgraded, `upgrade.sh` reinstalls the plugin from
|
||||
`local_requirements.txt` and runs migrations and `collectstatic`:
|
||||
|
||||
### Custom Images
|
||||
```bash
|
||||
sudo /opt/netbox/upgrade.sh
|
||||
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.
|
||||
## Uninstall
|
||||
|
||||

|
||||
1. Remove `"netbox_topology_views"` from `PLUGINS` and `PLUGINS_CONFIG`.
|
||||
2. Remove the line from `/opt/netbox/local_requirements.txt`.
|
||||
3. Uninstall the package and restart NetBox:
|
||||
|
||||
## Use
|
||||
```bash
|
||||
/opt/netbox/venv/bin/pip uninstall netbox-topology-views
|
||||
sudo systemctl restart netbox netbox-rq
|
||||
```
|
||||
|
||||
Go to the plugins tab in the navbar and click topology or go to `$NETBOX_URL/plugins/netbox_topology_views/` to view your topologies
|
||||
## Usage
|
||||
|
||||
Select your options for the topology view:
|
||||
### Classic topology
|
||||
|
||||

|
||||
**Plugins → Topology Views → Topology** provides the original, filterable
|
||||
topology view. It supports physical cables, logical connections, neighbour
|
||||
devices, circuit terminations, power feeds, wireless links, coordinate groups,
|
||||
and PNG and XML export.
|
||||
|
||||
<dl>
|
||||
<dt>Coordinate Group</dt>
|
||||
<dd>Select Coordinate Group. These groups allow devices to be displayed in different positions depending on the group, thus providing different representations for the same topology. If nothing is selected, the group "default" is set automatically.</dd>
|
||||
<dt>Save Coordinates</dt>
|
||||
<dd>Save the coordinates of devices in the topology view.</dd>
|
||||
<dt>Show Unconnected</dt>
|
||||
<dd>Show devices that have no connections or for which no connection is displayed. This option depends on other parameters like 'Show Cables' and 'Show Logical Connections'.</dd>
|
||||
<dt>Show Cables</dt>
|
||||
<dd>Show connections between interfaces, front / rear ports, etc., that are connected with one or more cables. These connections are displayed as solid lines in the color of the cable.</dd>
|
||||
<dt>Show Logical Connections</dt>
|
||||
<dd>Show logical connections between interfaces (referred to as Interface Connections in NetBox) in the topology view. Where the path between
|
||||
interfaces includes multiple cables (e.g., via patch panels), only the end interface connections are shown, not the
|
||||
intermediate front / rear port connections, etc. This is similar to what was referred to as 'end-to-end' connections in previous versions. These connections are displayed as yellow dotted lines.</dd>
|
||||
<dt>Show redundant Cable and Logical Connection</dt>
|
||||
<dd>Shows a logical connection (in addition to a cable), even if a cable is directly connected. Leaving this option disabled prevents that redundant display. This option only has an effect if 'Show Logical Connections' is activated.</dd>
|
||||
<dt>Show Neighbors</dt>
|
||||
<dd>Adds neighbors to the filter result set automatically. Link peers will be added if 'Show Cables' is ticked, far-end terminations will be added if 'Show Logical Connections' is ticked.</dd>
|
||||
<dt>Show Circuit Terminations</dt>
|
||||
<dd>Show connections which end at a circuit termination in the topology view. These connections are displayed as blue dashed lines.</dd>
|
||||
<dt>Show Power Feeds</dt>
|
||||
<dd>Displays connections between power outlets and power ports. These connections are displayed as solid lines in the color of the cable. This option depends on 'Show Cables'.</dd>
|
||||
<dt>Show Wireless Links</dt>
|
||||
<dd>Displays wireless connections. These connections are displayed as blue dotted lines.</dd>
|
||||
</dl>
|
||||
|
||||
### Coordinates and Coordinate Groups
|
||||
### Rack view
|
||||
|
||||
Netbox Topology Views stores the position of the devices. In order to allow different representations for the topology, Coordinate Groups are supported.
|
||||
**Plugins → Topology Views → More views → Rack view**
|
||||
|
||||
> 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.
|
||||
Select a single rack or several racks via site, location and rack; empty racks
|
||||
can be shown optionally. Devices are rendered according to position, height and
|
||||
rack face. Below the elevations follow:
|
||||
|
||||
Navigate to "Coordinate Groups" in the menu and create as many groups as you like. You can select a group later in the filter pane in order to show icon positions according to this group (see chapter "Use"). You can also omit creating a group if you don't need this feature. Netbox Topology Views automatically creates a group named "default" for you and stores all coordinates in this group, even if you do not select a group in the filter.
|
||||
1. the interactive cable topology, and
|
||||
2. a table of all involved cables.
|
||||
|
||||
By default, the position of the devices are calculated with a physics engine. As soon as a device icon is dragged to another location, its position is saved and excluded from the calculation by the physics engine. All saved coordinates can be viewed and edited under the menu item "Coordinates".
|
||||
In the cable topology, devices can be moved via drag and drop. Hovering a device
|
||||
highlights its cables and lists all connected ports; hovering a cable shows both
|
||||
terminations. A click opens the NetBox object.
|
||||
|
||||
> **_Note:_** At the time of writing, it is not possible to store the positions of circuit terminations, power panels and power feeds, as these are not devices.
|
||||
The export buttons save a single rack, all displayed racks or the cable topology
|
||||
as SVG, PNG or draw.io, always without background. In the draw.io export, rack
|
||||
frames, devices and labels remain individually editable, and topology cables are
|
||||
real edges that follow the devices.
|
||||
|
||||
### Permissions
|
||||
Positions are stored in the browser's `localStorage` and apply only to that
|
||||
browser and the specific rack/filter selection.
|
||||
|
||||
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
|
||||
With [Netbox-Utilities](https://git.mrblake.cc/MrBlake/Netbox-Utilities)
|
||||
installed, partial-width devices are rendered side by side in the rack view.
|
||||
|
||||
To save `Coordinates` when moving icons:
|
||||
+ netbox_topology_views | coordinate | change
|
||||
## Device icons
|
||||
|
||||
To view `/plugins/netbox_topology-views/images`:
|
||||
+ dcim | site | view
|
||||
+ dcim | device role | view
|
||||
+ dcim | device role | add
|
||||
+ dcim | device role | change
|
||||
Icons are assigned by device role slug. This fork additionally ships:
|
||||
|
||||
To view `/plugins/netbox_topology-views/individualoptions`:
|
||||
+ netbox_topology_views | individual options | change
|
||||
| Device role slug | Icon file |
|
||||
| --- | --- |
|
||||
| `network-switch` | `network-switch.svg` |
|
||||
| `leaf-switch` | `leaf-switch.svg` |
|
||||
| `spine-switch` | `spine-switch.svg` |
|
||||
| `aggregation-switch` | `aggregation-switch.svg` |
|
||||
|
||||
Set `Coordinate Groups` according to your needs:
|
||||
+ netbox_topology_views | coordinate groups | view/add/change/delete
|
||||
Custom images can still be assigned on the **Images** page. The default path is
|
||||
`netbox_topology_views/img` within `STATIC_ROOT`.
|
||||
|
||||
Set `Coordinates` according to your needs:
|
||||
+ netbox_topology_views | coordinate | view/add/change/delete
|
||||
## Permissions
|
||||
|
||||
Set `Power Feed Coordinates` according to your needs:
|
||||
+ netbox_topology_views | power feed coordinate | view/add/change/delete
|
||||
Classic topology: `dcim.view_site`, `dcim.view_device`.
|
||||
|
||||
Set `Power Panel Coordinates` according to your needs:
|
||||
+ netbox_topology_views | power panel coordinate | view/add/change/delete
|
||||
Rack view: `dcim.view_rack`, `dcim.view_device`, `dcim.view_cable`.
|
||||
|
||||
Set `Circuit Coordinates` according to your needs:
|
||||
+ netbox_topology_views | circuit coordinate | view/add/change/delete
|
||||
Saving classic topology coordinates additionally requires the corresponding
|
||||
permissions on `netbox_topology_views.coordinate`.
|
||||
|
||||
## Origin and credits
|
||||
|
||||
This project is a fork of the open-source plugin
|
||||
[NetBox Topology Views](https://github.com/mattieserver/netbox-topology-views).
|
||||
The original plugin was created by **Mattijs Vanhaverbeke**. Most of the classic
|
||||
topology view, data models, filters and export functions are based on his work
|
||||
and the contributions to the original project.
|
||||
|
||||
This fork is developed independently and is not officially affiliated with the
|
||||
original author or the NetBox project.
|
||||
|
||||
## License
|
||||
|
||||
Like the original, this project is licensed under the
|
||||
[Apache License 2.0](LICENSE). Existing copyright, license and attribution
|
||||
notices of the original project are retained.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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
|
||||
|
||||
@@ -4,11 +4,13 @@ from netbox.plugins import PluginConfig
|
||||
class TopologyViewsConfig(PluginConfig):
|
||||
name = "netbox_topology_views"
|
||||
verbose_name = "Topology views"
|
||||
description = "An plugin to render topology maps"
|
||||
version = "4.4.0"
|
||||
author = "Mattijs Vanhaverbeke"
|
||||
description = "A plugin to render topology maps"
|
||||
version = "4.5.2"
|
||||
author = "Mattijs Vanhaverbeke, Codex & Claude"
|
||||
author_email = "author@example.com"
|
||||
base_url = "netbox_topology_views"
|
||||
min_version = "4.7.0"
|
||||
max_version = "4.7.99"
|
||||
required_settings = []
|
||||
default_settings = {
|
||||
"static_image_directory": "netbox_topology_views/img",
|
||||
|
||||
@@ -50,4 +50,7 @@ class PowerFeedCoordinateSerializer(NetBoxModelSerializer):
|
||||
class IndividualOptionsSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = IndividualOptions
|
||||
fields = ("ignore_cable_type", "save_coords", "show_unconnected", "show_cables", "show_logical_connections", "show_single_cable_logical_conns", "show_neighbors", "show_circuit", "show_power", "show_wireless", "group_sites", "group_locations", "group_racks", "group_virtualchassis", "draw_default_layout", "straight_cables", "grid_size", "node_label_items")
|
||||
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")
|
||||
|
||||
@@ -114,7 +114,7 @@ class ExportTopoToXML(BaseViewSet, ViewSet):
|
||||
|
||||
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, grid_size, node_label_items = get_query_settings(request)
|
||||
filter_id, ignore_cable_type, save_coords, show_unconnected, show_power, show_circuit, show_logical_connections, show_single_cable_logical_conns, show_cables, show_wireless, group_sites, group_locations, group_racks, group_virtualchassis, group, show_neighbors, straight_cables, draw_termination_labels, draw_cable_labels, grid_size, node_label_items = get_query_settings(request)
|
||||
|
||||
# Read options from saved filters as NetBox does not handle custom plugin filters
|
||||
if "filter_id" in request.GET and request.GET["filter_id"] != '':
|
||||
@@ -136,7 +136,9 @@ class ExportTopoToXML(BaseViewSet, ViewSet):
|
||||
if group_racks == False and 'group_racks' in saved_filter_params: group_racks = saved_filter_params['group_racks']
|
||||
if group_virtualchassis == False and 'group_virtualchassis' in saved_filter_params: group_virtualchassis = saved_filter_params['group_virtualchassis']
|
||||
if show_neighbors == False and 'show_neighbors' in saved_filter_params: show_neighbors = saved_filter_params['show_neighbors']
|
||||
if straight_cables == False and 'straight_cables' in saved_filter_params: show_neighbors = saved_filter_params['straight_cables']
|
||||
if 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
|
||||
@@ -170,6 +172,8 @@ class ExportTopoToXML(BaseViewSet, ViewSet):
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ class NodeLabelItems(ChoiceSet):
|
||||
LOCATION = 'location'
|
||||
RACK = 'rack'
|
||||
VIRTUAL_CHASSIS = 'virtualchassis'
|
||||
ASSET_TAG = 'assettag'
|
||||
|
||||
CHOICES = [
|
||||
(DEVICE_NAME, 'Device Name'),
|
||||
@@ -31,4 +32,5 @@ class NodeLabelItems(ChoiceSet):
|
||||
(LOCATION, 'Location'),
|
||||
(RACK, 'Rack'),
|
||||
(VIRTUAL_CHASSIS, 'Virtual Chassis'),
|
||||
(ASSET_TAG, 'Asset Tag'),
|
||||
]
|
||||
|
||||
+113
-52
@@ -35,9 +35,13 @@ class DeviceFilterForm(
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet(
|
||||
'group', 'ignore_cable_type', 'save_coords', 'show_unconnected', 'show_cables', 'show_logical_connections',
|
||||
'show_single_cable_logical_conns', 'show_neighbors', 'show_circuit', 'show_power', 'show_wireless',
|
||||
'group_sites', 'group_locations', 'group_racks', 'group_virtualchassis', 'straight_cables', 'grid_size', 'node_label_items', name=_("Options")
|
||||
'group', 'ignore_cable_type', 'save_coords', 'show_unconnected', 'show_cables', 'show_wireless',
|
||||
'show_logical_connections', 'show_single_cable_logical_conns', 'show_neighbors',
|
||||
'group_sites', 'group_locations', 'group_racks', 'group_virtualchassis', 'straight_cables',
|
||||
'draw_termination_labels', 'draw_cable_labels', 'grid_size', 'node_label_items', name=_("Options")
|
||||
),
|
||||
FieldSet(
|
||||
'show_circuit', 'show_power', name=_("Additional filter-independent types (non-devices)")
|
||||
),
|
||||
FieldSet('id', name=_("Device")),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_("Location")),
|
||||
@@ -226,7 +230,6 @@ class DeviceFilterForm(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
|
||||
tag = TagFilterField(model)
|
||||
|
||||
# options
|
||||
@@ -261,7 +264,12 @@ class DeviceFilterForm(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
|
||||
show_wireless = forms.NullBooleanField(
|
||||
label =_('Show Wireless Links'), required=False, initial=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
show_logical_connections = forms.NullBooleanField(
|
||||
label =_('Show Logical Connections'), required=False, initial=False,
|
||||
widget=forms.Select(
|
||||
@@ -280,24 +288,6 @@ class DeviceFilterForm(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
show_circuit = forms.NullBooleanField(
|
||||
label=_('Show Circuit Terminations'), required=False, initial=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
show_power = forms.NullBooleanField(
|
||||
label=_('Show Power Feeds'), required=False, initial=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
show_wireless = forms.NullBooleanField(
|
||||
label =_('Show Wireless Links'), required=False, initial=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
group_sites = forms.NullBooleanField(
|
||||
label =_('Group Sites'), required=False, initial=False,
|
||||
widget=forms.Select(
|
||||
@@ -328,6 +318,18 @@ class DeviceFilterForm(
|
||||
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,
|
||||
@@ -342,6 +344,43 @@ class DeviceFilterForm(
|
||||
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")),
|
||||
@@ -522,21 +561,27 @@ class IndividualOptionsForm(NetBoxModelForm):
|
||||
'save_coords',
|
||||
'show_unconnected',
|
||||
'show_cables',
|
||||
'show_wireless',
|
||||
'show_logical_connections',
|
||||
'show_single_cable_logical_conns',
|
||||
'show_neighbors',
|
||||
'show_circuit',
|
||||
'show_power',
|
||||
'show_wireless',
|
||||
'group_sites',
|
||||
'group_locations',
|
||||
'group_racks',
|
||||
'group_virtualchassis',
|
||||
'draw_default_layout',
|
||||
'straight_cables',
|
||||
'draw_termination_labels',
|
||||
'draw_cable_labels',
|
||||
'grid_size',
|
||||
'node_label_items',
|
||||
),
|
||||
FieldSet
|
||||
(
|
||||
'show_circuit',
|
||||
'show_power',
|
||||
name=_("Additional filter-independent types (non-devices)")
|
||||
),
|
||||
)
|
||||
|
||||
user_id = forms.CharField(widget=forms.HiddenInput())
|
||||
@@ -588,6 +633,13 @@ class IndividualOptionsForm(NetBoxModelForm):
|
||||
'with one or more cables. These connections are displayed as solid '
|
||||
'lines in the color of the cable.')
|
||||
)
|
||||
show_wireless = forms.BooleanField(
|
||||
label =_('Show Wireless Links'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_('Displays wireless connections. These connections are '
|
||||
'displayed as blue dotted lines.')
|
||||
)
|
||||
show_logical_connections = forms.BooleanField(
|
||||
label =_('Show Logical Connections'),
|
||||
required=False,
|
||||
@@ -613,28 +665,6 @@ class IndividualOptionsForm(NetBoxModelForm):
|
||||
'Link peers will be added if \'Show Cables\' is ticked, far-end '
|
||||
'terminations will be added if \'Show Logical Connections\' is ticked.')
|
||||
)
|
||||
show_circuit = forms.BooleanField(
|
||||
label=_('Show Circuit Terminations'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_('Displays connections between circuit terminations. '
|
||||
'These connections are displayed as blue dashed lines.')
|
||||
)
|
||||
show_power = forms.BooleanField(
|
||||
label=_('Show Power Feeds'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_('Displays connections between power outlets and power '
|
||||
'ports. These connections are displayed as solid lines in the '
|
||||
'color of the cable. This option depends on \'Show Cables\'.')
|
||||
)
|
||||
show_wireless = forms.BooleanField(
|
||||
label =_('Show Wireless Links'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_('Displays wireless connections. These connections are '
|
||||
'displayed as blue dotted lines.')
|
||||
)
|
||||
group_sites = forms.BooleanField(
|
||||
label =_('Group Sites'),
|
||||
required=False,
|
||||
@@ -677,6 +707,19 @@ class IndividualOptionsForm(NetBoxModelForm):
|
||||
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,
|
||||
@@ -693,13 +736,31 @@ class IndividualOptionsForm(NetBoxModelForm):
|
||||
choices=NodeLabelItems,
|
||||
help_text=_('Choose Label Items that you want to be displayed.')
|
||||
)
|
||||
show_circuit = forms.BooleanField(
|
||||
label=_('Show Circuit Terminations'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_('Displays connections between circuit terminations. '
|
||||
'These connections are displayed as blue dashed lines. '
|
||||
'Be aware that Circuits cannot be filtered.')
|
||||
)
|
||||
show_power = forms.BooleanField(
|
||||
label=_('Show Power Feeds'),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_('Displays connections between power outlets and power '
|
||||
'ports. These connections are displayed as solid lines in the '
|
||||
'color of the cable. This option depends on \'Show Cables\'. '
|
||||
'Be aware that Power Feeds cannot be filtered.')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IndividualOptions
|
||||
fields = [
|
||||
'user_id', 'ignore_cable_type', 'preselected_device_roles', 'preselected_tags',
|
||||
'save_coords', 'show_unconnected', 'show_cables', 'show_logical_connections',
|
||||
'show_single_cable_logical_conns', 'show_neighbors', 'show_circuit', 'show_power',
|
||||
'show_wireless', 'group_sites', 'group_locations', 'group_racks', 'group_virtualchassis', 'draw_default_layout',
|
||||
'straight_cables', 'grid_size', 'node_label_items'
|
||||
'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 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),
|
||||
),
|
||||
]
|
||||
@@ -405,6 +405,12 @@ class IndividualOptions(NetBoxModel):
|
||||
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
|
||||
)
|
||||
|
||||
@@ -84,6 +84,11 @@ menu = PluginMenu(
|
||||
PluginMenuItem(link="plugins:netbox_topology_views:home", link_text="Topology", permissions=["dcim.view_site", "dcim.view_device"]),
|
||||
),
|
||||
),
|
||||
('WEITERE ANSICHTEN',
|
||||
(
|
||||
PluginMenuItem(link="plugins:netbox_topology_views:rack_elevation", link_text="Rack-Ansicht", permissions=["dcim.view_rack", "dcim.view_device", "dcim.view_cable"]),
|
||||
),
|
||||
),
|
||||
('COORDINATES',
|
||||
(
|
||||
PluginMenuItem(link="plugins:netbox_topology_views:coordinategroup_list", link_text="Coordinate Groups", buttons=coordinategroup_buttons, permissions=['netbox_topology_views.view_coordinategroup']),
|
||||
|
||||
@@ -1 +1 @@
|
||||
#visgraph{height:64vh;border:1px solid #ced4da}html[data-netbox-color-mode=dark] #visgraph{background-color:#212529;border:1px solid #495057}.image-dropdown img{width:64px;height:64px}.image-dropdown-content{display:flex;flex-wrap:wrap;gap:.5rem;padding-inline:.5rem;width:50vw;max-width:32rem}.image-dropdown-content>img{cursor:pointer}
|
||||
#visgraph{height:64vh;border:1px solid #ced4da}html[data-netbox-color-mode=dark] #visgraph{background-color:#212529;border:1px solid #495057}.image-dropdown img{width:64px;height:64px}.image-dropdown-content{display:flex;flex-wrap:wrap;gap:.5rem;padding-inline:.5rem;width:50vw;max-width:32rem}.image-dropdown-content>img{cursor:pointer}.rack-elevation-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(34rem,1fr));gap:1rem}.rack-card{border:1px solid var(--tblr-border-color,#ced4da);border-radius:.4rem;background:var(--tblr-bg-surface,#fff);padding:1rem}.rack-card>header{display:flex;justify-content:space-between;margin-bottom:.75rem}.rack-faces{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}.rack-face-title{text-align:center;font-size:.75rem;text-transform:uppercase;margin-bottom:.25rem}.rack-frame{display:flex;height:calc(var(--rack-units)*14px);min-height:280px;max-height:70vh;border:4px solid #3b4654;background:repeating-linear-gradient(to top,rgba(127,127,127,.14) 0,rgba(127,127,127,.14) 1px,transparent 1px,transparent calc(100%/var(--rack-units)))}.rack-unit-labels{width:2rem;display:flex;flex-direction:column;justify-content:space-around;font-size:.55rem;text-align:center;color:var(--tblr-secondary,#667382)}.rack-device-area{position:relative;flex:1;border-left:1px solid #3b4654}.rack-device{position:absolute;left:3px;right:3px;min-height:2px;overflow:hidden;padding:1px 5px;border:2px solid #1685fc;border-radius:3px;background:#162e45;color:#fff;line-height:1.1;text-decoration:none}.rack-device:hover{filter:brightness(1.25);color:#fff;z-index:2}.rack-device span,.rack-device small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rack-device small{font-size:.6rem;opacity:.8}@media(max-width:700px){.rack-elevation-grid{grid-template-columns:1fr}.rack-faces{grid-template-columns:1fr}}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
.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-edge.is-power {
|
||||
stroke-dasharray: 2 4;
|
||||
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 |
File diff suppressed because one or more lines are too long
@@ -26,3 +26,69 @@ html[data-netbox-color-mode=dark] #visgraph {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.rack-elevation-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(34rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.rack-card {
|
||||
border: 1px solid var(--tblr-border-color, #ced4da);
|
||||
border-radius: .4rem;
|
||||
background: var(--tblr-bg-surface, #fff);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.rack-card > header { display: flex; justify-content: space-between; margin-bottom: .75rem; }
|
||||
.rack-faces { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
|
||||
.rack-face-title { text-align: center; font-size: .75rem; text-transform: uppercase; margin-bottom: .25rem; }
|
||||
.rack-frame { display: flex; height: calc(var(--rack-units) * 22px); min-height: 280px; border: 4px solid #3b4654; background: repeating-linear-gradient(to top, rgba(127,127,127,.14) 0, rgba(127,127,127,.14) 1px, transparent 1px, transparent calc(100% / var(--rack-units))); }
|
||||
.rack-unit-labels { width: 2rem; display: flex; flex-direction: column; justify-content: space-around; font-size: .55rem; text-align: center; color: var(--tblr-secondary, #667382); }
|
||||
.rack-device-area { position: relative; flex: 1; border-left: 1px solid #3b4654; }
|
||||
.rack-device { position: absolute; left: 3px; right: 3px; min-height: 2px; overflow: hidden; padding: 2px 6px; border: 2px solid #1685fc; border-radius: 3px; background: #eef6ff; color: #132238; font-size: .78rem; line-height: 1.15; text-decoration: none; }
|
||||
.rack-device:hover { min-height: 2.8rem; overflow: visible; filter: brightness(1.25); color: #fff; z-index: 3; }
|
||||
.rack-device:hover span { white-space: normal; }
|
||||
.rack-device span, .rack-device small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rack-device small { font-size: .6rem; opacity: .8; }
|
||||
.rack-topology { position: relative; min-height: 18rem; overflow: auto; padding: 1rem; background: var(--tblr-bg-surface-secondary, rgba(127,127,127,.05)); }
|
||||
.rack-topology-lines { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1; }
|
||||
.rack-topology-columns { position: relative; z-index: 2; display: flex; align-items: flex-start; justify-content: space-around; gap: 5rem; min-width: max-content; pointer-events: none; }
|
||||
.rack-topology-column { display: flex; flex-direction: column; gap: .75rem; width: 15rem; }
|
||||
.rack-topology-column h4 { text-align: center; font-size: .85rem; }
|
||||
.rack-topology-node { display: block; padding: .55rem .75rem; border: 2px solid var(--node-color); border-radius: .35rem; background: var(--tblr-bg-surface, #fff); color: inherit; text-decoration: none; box-shadow: 0 2px 6px rgba(0,0,0,.12); cursor: grab; touch-action: none; user-select: none; pointer-events: auto; }
|
||||
.rack-topology-node.is-dragging { cursor: grabbing; z-index: 5; }
|
||||
.rack-topology-node:hover { color: inherit; filter: brightness(.95); }
|
||||
.rack-topology-node strong, .rack-topology-node small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rack-topology-node small { opacity: .7; }
|
||||
.rack-topology-edge { fill: none; stroke-width: 3; opacity: .8; pointer-events: stroke; cursor: pointer; transition: opacity .12s, stroke .12s, filter .12s, stroke-width .12s; }
|
||||
.rack-topology-edge.is-highlighted { stroke: var(--topology-highlight-color, #16a34a) !important; stroke-width: 5; opacity: 1; filter: drop-shadow(0 0 5px var(--topology-highlight-color, #16a34a)); }
|
||||
.rack-topology-edge.is-dimmed { opacity: .12; }
|
||||
.rack-topology-tooltip { display: none; position: absolute; z-index: 10; width: max-content; max-width: 28rem; padding: .65rem .8rem; border: 1px solid var(--tblr-border-color, #ced4da); border-radius: .35rem; background: var(--tblr-bg-surface, #fff); box-shadow: 0 4px 18px rgba(0,0,0,.25); pointer-events: none; }
|
||||
.rack-topology-tooltip.is-visible { display: block; }
|
||||
.rack-topology-tooltip > strong, .rack-topology-tooltip > span, .rack-topology-tooltip li span, .rack-topology-tooltip li small { display: block; }
|
||||
.rack-topology-tooltip ul { margin: .4rem 0 0; padding: 0; list-style: none; }
|
||||
.rack-topology-tooltip li + li { margin-top: .45rem; padding-top: .45rem; border-top: 1px solid var(--tblr-border-color, #ced4da); }
|
||||
.rack-topology-tooltip small { opacity: .65; }
|
||||
.rack-cable-count { min-width: 2rem; margin-left: .35rem; background-color: #52606d !important; color: #fff !important; text-align: center; }
|
||||
|
||||
.rack-frame {
|
||||
border-color: #718096;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.rack-topology-device-list { display: flex; flex-direction: column; gap: 40px; }
|
||||
.rack-topology-edge.is-wireless { stroke-dasharray: 7 7; stroke-linecap: round; }
|
||||
#rack-topology-card:fullscreen { margin: 0 !important; border: 0; border-radius: 0; background: var(--tblr-bg-surface, #fff); display: flex; flex-direction: column; overflow: hidden; }
|
||||
#rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; overflow: hidden; }
|
||||
#rack-topology-card:fullscreen .rack-topology { height: calc(100vh - 5.5rem); }
|
||||
|
||||
html[data-bs-theme="dark"], body[data-bs-theme="dark"], html[data-netbox-color-mode="dark"] {
|
||||
.rack-frame { border-color: #3b4654; background-color: #212b36; }
|
||||
.rack-device { background: #162e45; color: #fff; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.rack-elevation-grid { grid-template-columns: 1fr; }
|
||||
.rack-faces { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import { DataSet } from 'vis-data/esnext'
|
||||
import { Network } from 'vis-network/esnext'
|
||||
|
||||
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: {
|
||||
hover: true,
|
||||
@@ -62,7 +100,21 @@ const coordSaveCheckbox = document.querySelector('#id_save_coords')
|
||||
const edges = new DataSet(
|
||||
topologyData.edges.map((node) => ({
|
||||
...node,
|
||||
title: htmlTitle(node.title)
|
||||
title: htmlTitle(node.title),
|
||||
...(node.drawTerminationLabel && {
|
||||
arrows: {
|
||||
from: {
|
||||
enabled: true,
|
||||
type: "image",
|
||||
src: generatePortSVG(node.cable_a_name)
|
||||
},
|
||||
to: {
|
||||
enabled: true,
|
||||
type: "image",
|
||||
src: generatePortSVG(node.cable_b_name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
)
|
||||
|
||||
|
||||
+11
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "netbox_topology_views",
|
||||
"version": "4.3.0",
|
||||
"version": "4.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "netbox_topology_views",
|
||||
"version": "4.3.0",
|
||||
"version": "4.5.0",
|
||||
"dependencies": {
|
||||
"vis-data": "^7.1.9",
|
||||
"vis-network": "^9.1.9",
|
||||
@@ -904,10 +904,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz",
|
||||
"integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==",
|
||||
"dev": true
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.1",
|
||||
@@ -990,10 +991,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "netbox_topology_views",
|
||||
"version": "4.4.0",
|
||||
"version": "4.5.1",
|
||||
"scripts": {
|
||||
"bundle": "node bundle.js",
|
||||
"bundle:styles": "node bundle.js --styles",
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
{% 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 align-items-center">
|
||||
<div class="d-flex gap-3 align-items-center rack-topology-kind-toggles">
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="rack-topology-show-wireless" checked>
|
||||
<label class="form-check-label" for="rack-topology-show-wireless">Funk/WLAN</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="rack-topology-show-power" checked>
|
||||
<label class="form-check-label" for="rack-topology-show-power">Strom</label>
|
||||
</div>
|
||||
</div>
|
||||
<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 isPowerEdge = edge => edge.kind === 'power';
|
||||
const showWirelessCheckbox = document.getElementById('rack-topology-show-wireless');
|
||||
const showPowerCheckbox = document.getElementById('rack-topology-show-power');
|
||||
const isEdgeVisible = edge => {
|
||||
if (isWirelessEdge(edge)) return showWirelessCheckbox ? showWirelessCheckbox.checked : true;
|
||||
if (isPowerEdge(edge)) return showPowerCheckbox ? showPowerCheckbox.checked : true;
|
||||
return true;
|
||||
};
|
||||
const applyKindVisibility = () => {
|
||||
const showWireless = showWirelessCheckbox ? showWirelessCheckbox.checked : true;
|
||||
root.querySelectorAll('.rack-topology-node').forEach(node => {
|
||||
if (node.dataset.kind === 'wireless-lan') {
|
||||
node.style.display = showWireless ? '' : 'none';
|
||||
}
|
||||
});
|
||||
drawEdges();
|
||||
};
|
||||
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.kind) item.dataset.kind = node.kind;
|
||||
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 => {
|
||||
if (!isEdgeVisible(edge)) return;
|
||||
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');
|
||||
if (isPowerEdge(edge)) path.classList.add('is-power');
|
||||
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 · ' : isPowerEdge(edge) ? 'Strom · ' : ''}${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 => isEdgeVisible(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 · ' : isPowerEdge(edge) ? 'Strom · ' : ''}${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 => {
|
||||
if (node.style.display === 'none') return;
|
||||
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 => {
|
||||
if (!isEdgeVisible(edge)) return;
|
||||
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);
|
||||
const dashPattern = isWirelessEdge(edge) ? '7 7' : isPowerEdge(edge) ? '2 4' : null;
|
||||
const edgeLabelPrefix = isWirelessEdge(edge) ? 'WLAN · ' : isPowerEdge(edge) ? 'Strom · ' : '';
|
||||
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"${dashPattern ? ` stroke-dasharray="${dashPattern}"` : ''}/>`;
|
||||
const sourceId = nodeCells.get(edge.from), targetId = nodeCells.get(edge.to);
|
||||
if (sourceId && targetId) {
|
||||
drawioCells += `<mxCell id="edge-${drawioId++}" value="${xmlEscape(`${edgeLabelPrefix}${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;${dashPattern ? `dashed=1;dashPattern=${dashPattern};` : ''}" 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');
|
||||
}));
|
||||
showWirelessCheckbox?.addEventListener('change', applyKindVisibility);
|
||||
showPowerCheckbox?.addEventListener('change', applyKindVisibility);
|
||||
requestAnimationFrame(applyKindVisibility);
|
||||
window.addEventListener('resize', drawEdges);
|
||||
root.addEventListener('scroll', drawEdges, {passive: true});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -8,6 +8,7 @@ from . import models, views
|
||||
urlpatterns = (
|
||||
path("", RedirectView.as_view(url="topology/", permanent=True)),
|
||||
path("topology/", views.TopologyHomeView.as_view(), name="home"),
|
||||
path("rack-elevation/", views.RackElevationView.as_view(), name="rack_elevation"),
|
||||
path("images/", views.TopologyImagesView.as_view(), name="images"),
|
||||
path("individualoptions/", views.TopologyIndividualOptionsView.as_view(), name="individualoptions"),
|
||||
|
||||
|
||||
@@ -196,6 +196,16 @@ def get_query_settings(request):
|
||||
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')
|
||||
@@ -204,7 +214,7 @@ def get_query_settings(request):
|
||||
if "node_label_items" in request.GET:
|
||||
node_label_items = request.GET.getlist('node_label_items')
|
||||
|
||||
return filter_id, ignore_cable_type, save_coords, show_unconnected, show_power, show_circuit, show_logical_connections, show_single_cable_logical_conns, show_cables, show_wireless, group_sites, group_locations, group_racks, group_virtualchassis, group, show_neighbors, straight_cables, grid_size, node_label_items
|
||||
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]
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import DefaultDict, Dict, Optional, Union
|
||||
from itertools import chain
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination, ProviderNetwork
|
||||
from dcim.choices import CableTypeChoices
|
||||
from dcim.models import (
|
||||
Cable,
|
||||
CableTermination,
|
||||
@@ -15,6 +16,7 @@ from dcim.models import (
|
||||
PowerFeed,
|
||||
PowerPanel,
|
||||
RearPort,
|
||||
Rack,
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
@@ -26,7 +28,7 @@ from django.http import HttpRequest, HttpResponseRedirect, QueryDict
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.views.generic import View
|
||||
from extras.models import Tag, SavedFilter
|
||||
from wireless.models import WirelessLink
|
||||
from wireless.models import WirelessLAN, WirelessLink
|
||||
from netbox.views.generic import (
|
||||
ObjectView,
|
||||
ObjectListView,
|
||||
@@ -38,6 +40,7 @@ from netbox.views.generic import (
|
||||
from netbox_topology_views.filters import DeviceFilterSet, CoordinatesFilterSet, CircuitCoordinatesFilterSet, PowerPanelCoordinatesFilterSet, PowerFeedCoordinatesFilterSet
|
||||
from netbox_topology_views.forms import (
|
||||
DeviceFilterForm,
|
||||
RackElevationFilterForm,
|
||||
IndividualOptionsForm,
|
||||
CoordinateGroupsForm,
|
||||
CircuitCoordinatesForm,
|
||||
@@ -247,6 +250,7 @@ def create_node(
|
||||
NodeLabelItems.LOCATION: getattr(device, 'location', None),
|
||||
NodeLabelItems.RACK: getattr(device, 'rack', None),
|
||||
NodeLabelItems.VIRTUAL_CHASSIS: getattr(device, 'virtual_chassis', None),
|
||||
NodeLabelItems.ASSET_TAG: getattr(device, 'asset_tag', None),
|
||||
}
|
||||
|
||||
label_items = []
|
||||
@@ -269,6 +273,8 @@ def create_edge(
|
||||
termination_a: Dict,
|
||||
termination_b: Dict,
|
||||
straight_cables: bool,
|
||||
draw_termination_labels: bool,
|
||||
draw_cable_labels: bool,
|
||||
circuit: Optional[Dict] = None,
|
||||
cable: Optional[Cable] = None,
|
||||
wireless: Optional[Dict] = None,
|
||||
@@ -322,8 +328,10 @@ def create_edge(
|
||||
edge["color"] = '#f1c232'
|
||||
edge["href"] = interface.get_absolute_url() + "trace"
|
||||
|
||||
if cable is not None and cable.label:
|
||||
if cable is not None and hasattr(cable, "label") and cable.label:
|
||||
cable_label = "<br>Label: " + cable.label
|
||||
if draw_cable_labels is True:
|
||||
edge["label"] = cable.label
|
||||
else:
|
||||
cable_label = ""
|
||||
|
||||
@@ -339,6 +347,12 @@ def create_edge(
|
||||
# if straight_cables == True: edge["smooth"] = False
|
||||
edge["smooth"] = not straight_cables
|
||||
|
||||
if draw_termination_labels is True:
|
||||
edge["drawTerminationLabel"] = True
|
||||
edge["cable_a_name"] = cable_a_name
|
||||
edge["cable_b_name"] = cable_b_name
|
||||
|
||||
|
||||
return edge
|
||||
|
||||
|
||||
@@ -381,6 +395,8 @@ def get_topology_data(
|
||||
group_virtualchassis: bool,
|
||||
group_id,
|
||||
straight_cables: bool,
|
||||
draw_termination_labels: bool,
|
||||
draw_cable_labels: bool,
|
||||
grid_size: list,
|
||||
node_label_items: list,
|
||||
):
|
||||
@@ -479,6 +495,8 @@ def get_topology_data(
|
||||
termination_a=termination_a,
|
||||
termination_b=termination_b,
|
||||
straight_cables=straight_cables,
|
||||
draw_termination_labels=draw_termination_labels,
|
||||
draw_cable_labels=draw_cable_labels,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -549,6 +567,8 @@ def get_topology_data(
|
||||
termination_b=termination_b,
|
||||
power=True,
|
||||
straight_cables=straight_cables,
|
||||
draw_termination_labels=draw_termination_labels,
|
||||
draw_cable_labels=draw_cable_labels,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -587,7 +607,7 @@ def get_topology_data(
|
||||
edge_ids += 1
|
||||
termination_a = { "termination_name": interface.name, "termination_device_name": interface.device.name, "device_id": interface.device.id }
|
||||
termination_b = { "termination_name": destination.name, "termination_device_name": destination.device.name, "device_id": destination.device.id }
|
||||
edges.append(create_edge(edge_id=edge_ids, termination_a=termination_a, termination_b=termination_b, interface=interface, straight_cables=straight_cables))
|
||||
edges.append(create_edge(edge_id=edge_ids, termination_a=termination_a, termination_b=termination_b, interface=interface, straight_cables=straight_cables, draw_termination_labels=draw_termination_labels, draw_cable_labels=draw_cable_labels))
|
||||
nodes_devices[interface.device.id] = interface.device
|
||||
nodes_devices[destination.device.id] = destination.device
|
||||
|
||||
@@ -668,6 +688,8 @@ def get_topology_data(
|
||||
termination_a=termination_a,
|
||||
termination_b=termination_b,
|
||||
straight_cables=straight_cables,
|
||||
draw_termination_labels=draw_termination_labels,
|
||||
draw_cable_labels=draw_cable_labels,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -708,6 +730,8 @@ def get_topology_data(
|
||||
termination_b=termination_b,
|
||||
wireless=wireless,
|
||||
straight_cables=straight_cables,
|
||||
draw_termination_labels=draw_termination_labels,
|
||||
draw_cable_labels=draw_cable_labels,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -762,7 +786,7 @@ class TopologyHomeView(PermissionRequiredMixin, View):
|
||||
|
||||
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, grid_size, node_label_items = get_query_settings(request)
|
||||
filter_id, ignore_cable_type, save_coords, show_unconnected, show_power, show_circuit, show_logical_connections, show_single_cable_logical_conns, show_cables, show_wireless, group_sites, group_locations, group_racks, group_virtualchassis, group, show_neighbors, straight_cables, draw_termination_labels, draw_cable_labels, grid_size, node_label_items = get_query_settings(request)
|
||||
|
||||
filter_required = True
|
||||
empty_result = False
|
||||
@@ -788,6 +812,8 @@ class TopologyHomeView(PermissionRequiredMixin, View):
|
||||
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
|
||||
@@ -825,17 +851,19 @@ class TopologyHomeView(PermissionRequiredMixin, View):
|
||||
group_virtualchassis=group_virtualchassis,
|
||||
group_id=group_id,
|
||||
straight_cables=straight_cables,
|
||||
draw_termination_labels=draw_termination_labels,
|
||||
draw_cable_labels=draw_cable_labels,
|
||||
grid_size=grid_size,
|
||||
node_label_items=node_label_items,
|
||||
)
|
||||
|
||||
if not topo_data["nodes"]:
|
||||
if topo_data is None or not topo_data["nodes"]:
|
||||
empty_result = True
|
||||
|
||||
else:
|
||||
# No GET-Request in URL. We most likely came here from the navigation menu.
|
||||
preselected_device_roles = IndividualOptions.objects.get(id=individualOptions.id).preselected_device_roles.all().values_list('id', flat=True)
|
||||
preselected_tags = IndividualOptions.objects.get(id=individualOptions.id).preselected_tags.all().values_list(Lower('name'), flat=True)
|
||||
preselected_tags = IndividualOptions.objects.get(id=individualOptions.id).preselected_tags.all().values_list('slug', flat=True)
|
||||
ignore_cable_type = IndividualOptions.objects.get(id=individualOptions.id).ignore_cable_type.translate({ord(i): None for i in '[]\''}).split(', ')
|
||||
if ignore_cable_type == ['']: ignore_cable_type = []
|
||||
|
||||
@@ -858,6 +886,8 @@ class TopologyHomeView(PermissionRequiredMixin, View):
|
||||
if individualOptions.group_racks: q['group_racks'] = "True"
|
||||
if individualOptions.group_virtualchassis: q['group_virtualchassis'] = "True"
|
||||
if individualOptions.straight_cables: q['straight_cables'] = "True"
|
||||
if individualOptions.draw_termination_labels: q['draw_termination_labels'] = "True"
|
||||
if individualOptions.draw_cable_labels: q['draw_cable_labels'] = "True"
|
||||
if individualOptions.grid_size: q['grid_size'] = individualOptions.grid_size
|
||||
node_label_items = IndividualOptions.objects.get(id=individualOptions.id).node_label_items.translate({ord(i): None for i in '[]\''}).split(', ')
|
||||
if node_label_items == ['']: node_label_items = []
|
||||
@@ -886,6 +916,197 @@ class TopologyHomeView(PermissionRequiredMixin, View):
|
||||
)
|
||||
|
||||
|
||||
def _rack_endpoint(termination):
|
||||
"""Return a template-friendly representation of any cable termination."""
|
||||
device = getattr(termination, "device", None)
|
||||
return {
|
||||
"node_id": f"device-{device.pk}" if device else f"external-{termination._meta.model_name}-{termination.pk}",
|
||||
"device_id": getattr(device, "pk", None),
|
||||
"device_name": getattr(device, "name", None) or "External",
|
||||
"name": getattr(termination, "name", str(termination)),
|
||||
"rack_id": getattr(device, "rack_id", None),
|
||||
"rack_name": getattr(getattr(device, "rack", None), "name", None) or "Extern",
|
||||
"url": device.get_absolute_url() if device else None,
|
||||
"color": getattr(getattr(device, "role", None), "color", None) or "1685fc",
|
||||
"position": float(device.position or 1) if device else None,
|
||||
"height": float(max(getattr(getattr(device, "device_type", None), "u_height", 1) or 1, 1)) if device else 1,
|
||||
"face": str(device.face or "front").lower() if device else "front",
|
||||
"rack_u_height": getattr(getattr(device, "rack", None), "u_height", None),
|
||||
"rack_desc_units": getattr(getattr(device, "rack", None), "desc_units", False),
|
||||
"rack_starting_unit": getattr(getattr(device, "rack", None), "starting_unit", 1),
|
||||
"subtitle": str(device.device_type) if device else getattr(termination, "name", str(termination)),
|
||||
}
|
||||
|
||||
|
||||
class RackElevationView(PermissionRequiredMixin, View):
|
||||
"""Render one or more complete rack elevations and their physical cabling."""
|
||||
|
||||
permission_required = ("dcim.view_rack", "dcim.view_device", "dcim.view_cable")
|
||||
|
||||
def get(self, request):
|
||||
racks = Rack.objects.restrict(request.user, "view").select_related("site", "location")
|
||||
if not request.GET:
|
||||
racks = racks.none()
|
||||
selected_racks = request.GET.getlist("rack_id")
|
||||
if selected_racks:
|
||||
racks = racks.filter(pk__in=selected_racks)
|
||||
else:
|
||||
if request.GET.getlist("site_id"):
|
||||
racks = racks.filter(site_id__in=request.GET.getlist("site_id"))
|
||||
if request.GET.getlist("location_id"):
|
||||
racks = racks.filter(location_id__in=request.GET.getlist("location_id"))
|
||||
|
||||
devices = Device.objects.restrict(request.user, "view").filter(
|
||||
rack_id__in=racks.values("pk")
|
||||
).select_related("device_type", "role", "rack")
|
||||
if request.GET.get("include_empty") not in ("on", "True", "true", "1"):
|
||||
racks = racks.filter(devices__in=devices).distinct()
|
||||
|
||||
rack_list = list(racks.order_by("site__name", "location__name", "name"))
|
||||
rack_ids = {rack.pk for rack in rack_list}
|
||||
rack_devices = {rack_id: [] for rack_id in rack_ids}
|
||||
device_ids = []
|
||||
for device in devices.filter(rack_id__in=rack_ids).order_by("rack_id", "position", "name"):
|
||||
device_ids.append(device.pk)
|
||||
height = float(max(getattr(device.device_type, "u_height", 1) or 1, 1))
|
||||
rack_height = max(device.rack.u_height or 1, 1)
|
||||
rack_start = float(device.rack.starting_unit or 1)
|
||||
position_offset = float(device.position or rack_start) - rack_start
|
||||
if device.rack.desc_units:
|
||||
bottom_units = rack_height - position_offset - height
|
||||
else:
|
||||
bottom_units = position_offset
|
||||
device.elevation_bottom = f"{max(bottom_units, 0) / rack_height * 100:.4f}"
|
||||
device.elevation_height = f"{min(height / rack_height * 100, 100):.4f}"
|
||||
device.elevation_face = str(device.face or "front").lower()
|
||||
rack_devices[device.rack_id].append(device)
|
||||
|
||||
for rack in rack_list:
|
||||
rack.elevation_devices = rack_devices[rack.pk]
|
||||
rack_start = rack.starting_unit or 1
|
||||
if rack.desc_units:
|
||||
rack.elevation_unit_labels = range(rack_start, rack_start + (rack.u_height or 0))
|
||||
else:
|
||||
rack.elevation_unit_labels = range(
|
||||
rack_start + (rack.u_height or 0) - 1,
|
||||
rack_start - 1,
|
||||
-1,
|
||||
)
|
||||
|
||||
rack_export = []
|
||||
for rack in rack_list:
|
||||
rack_export.append({
|
||||
"id": rack.pk, "name": rack.name, "u_height": rack.u_height,
|
||||
"starting_unit": rack.starting_unit, "desc_units": rack.desc_units,
|
||||
"site": str(rack.site), "location": str(rack.location or ""),
|
||||
"devices": [{
|
||||
"name": device.name or str(device.device_type),
|
||||
"type": str(device.device_type),
|
||||
"position": float(device.position or 1),
|
||||
"height": float(max(device.device_type.u_height or 1, 1)),
|
||||
"face": device.elevation_face,
|
||||
"color": f"#{device.role.color}" if device.role.color else "#1685fc",
|
||||
} for device in rack.elevation_devices],
|
||||
})
|
||||
|
||||
power_component_models = {"powerport", "poweroutlet", "powerfeed"}
|
||||
|
||||
cable_rows = []
|
||||
topology_nodes = {}
|
||||
topology_edges = []
|
||||
if device_ids:
|
||||
cable_ids = CableTermination.objects.filter(
|
||||
_device_id__in=device_ids
|
||||
).values_list("cable_id", flat=True).distinct()
|
||||
cables = Cable.objects.restrict(request.user, "view").filter(pk__in=cable_ids)
|
||||
for cable in cables.order_by("label", "pk"):
|
||||
a_terms, b_terms = list(cable.a_terminations), list(cable.b_terminations)
|
||||
is_power_cable = cable.type == CableTypeChoices.TYPE_POWER or any(
|
||||
term._meta.model_name in power_component_models for term in a_terms + b_terms
|
||||
)
|
||||
for index in range(max(len(a_terms), len(b_terms), 1)):
|
||||
a = _rack_endpoint(a_terms[index]) if index < len(a_terms) else None
|
||||
b = _rack_endpoint(b_terms[index]) if index < len(b_terms) else None
|
||||
if (a and a["rack_id"] in rack_ids) or (b and b["rack_id"] in rack_ids):
|
||||
cable_rows.append({"cable": cable, "a": a, "b": b})
|
||||
for endpoint in (a, b):
|
||||
if endpoint:
|
||||
topology_nodes[endpoint["node_id"]] = endpoint
|
||||
if a and b:
|
||||
default_color = "#f59e0b" if is_power_cable else "#1685fc"
|
||||
topology_edges.append({
|
||||
"from": a["node_id"], "to": b["node_id"],
|
||||
"from_device": a["device_name"], "to_device": b["device_name"],
|
||||
"from_port": a["name"], "to_port": b["name"],
|
||||
"label": cable.label or str(cable),
|
||||
"color": f"#{cable.color}" if cable.color else default_color,
|
||||
"url": cable.get_absolute_url(),
|
||||
"kind": "power" if is_power_cable else "cable",
|
||||
})
|
||||
|
||||
wireless_device_ids = set(device_ids) | {
|
||||
node["device_id"] for node in topology_nodes.values() if node.get("device_id")
|
||||
}
|
||||
wireless_links = WirelessLink.objects.restrict(request.user, "view").filter(
|
||||
Q(interface_a__device_id__in=wireless_device_ids) |
|
||||
Q(interface_b__device_id__in=wireless_device_ids)
|
||||
).select_related(
|
||||
"interface_a__device__rack", "interface_a__device__role", "interface_a__device__device_type",
|
||||
"interface_b__device__rack", "interface_b__device__role", "interface_b__device__device_type",
|
||||
)
|
||||
for wireless_link in wireless_links:
|
||||
a = _rack_endpoint(wireless_link.interface_a)
|
||||
b = _rack_endpoint(wireless_link.interface_b)
|
||||
topology_nodes[a["node_id"]] = a
|
||||
topology_nodes[b["node_id"]] = b
|
||||
topology_edges.append({
|
||||
"from": a["node_id"], "to": b["node_id"],
|
||||
"from_device": a["device_name"], "to_device": b["device_name"],
|
||||
"from_port": a["name"], "to_port": b["name"],
|
||||
"label": wireless_link.ssid or str(wireless_link),
|
||||
"color": "#8b5cf6", "url": wireless_link.get_absolute_url(),
|
||||
"kind": "wireless",
|
||||
})
|
||||
|
||||
wireless_lans = WirelessLAN.objects.restrict(request.user, "view").filter(
|
||||
interfaces__device_id__in=device_ids
|
||||
).distinct()
|
||||
for wireless_lan in wireless_lans:
|
||||
wlan_node_id = f"wlan-{wireless_lan.pk}"
|
||||
topology_nodes[wlan_node_id] = {
|
||||
"node_id": wlan_node_id, "device_id": None,
|
||||
"device_name": f"WLAN · {wireless_lan.ssid}",
|
||||
"name": wireless_lan.ssid, "subtitle": "Multi-Access WLAN",
|
||||
"rack_id": None, "rack_name": "Funknetze",
|
||||
"url": wireless_lan.get_absolute_url(), "color": "8b5cf6",
|
||||
"position": None, "height": 1, "face": "front", "rack_u_height": None,
|
||||
"kind": "wireless-lan",
|
||||
}
|
||||
wlan_interfaces = Interface.objects.restrict(request.user, "view").filter(
|
||||
wireless_lans=wireless_lan
|
||||
).select_related("device__rack", "device__role", "device__device_type")
|
||||
for interface in wlan_interfaces:
|
||||
endpoint = _rack_endpoint(interface)
|
||||
topology_nodes[endpoint["node_id"]] = endpoint
|
||||
topology_edges.append({
|
||||
"from": endpoint["node_id"], "to": wlan_node_id,
|
||||
"from_device": endpoint["device_name"],
|
||||
"to_device": f"WLAN · {wireless_lan.ssid}",
|
||||
"from_port": endpoint["name"], "to_port": wireless_lan.ssid,
|
||||
"label": wireless_lan.ssid, "color": "#8b5cf6",
|
||||
"url": wireless_lan.get_absolute_url(), "kind": "wireless-lan",
|
||||
})
|
||||
|
||||
return render(request, "netbox_topology_views/rack_elevation.html", {
|
||||
"filter_form": RackElevationFilterForm(request.GET or None),
|
||||
"racks": rack_list, "cables": cable_rows,
|
||||
"rack_topology": {"nodes": list(topology_nodes.values()), "edges": topology_edges},
|
||||
"rack_export": rack_export,
|
||||
"faces": (("front", "Vorderseite"), ("rear", "Rückseite")),
|
||||
"has_selection": bool(request.GET),
|
||||
})
|
||||
|
||||
|
||||
CONFIG = settings.PLUGINS_CONFIG["netbox_topology_views"]
|
||||
ADDITIONAL_ROLES = (PowerPanel, PowerFeed, Circuit)
|
||||
|
||||
@@ -1211,6 +1432,8 @@ class TopologyIndividualOptionsView(PermissionRequiredMixin, View):
|
||||
'group_virtualchassis': queryset.group_virtualchassis,
|
||||
'draw_default_layout': queryset.draw_default_layout,
|
||||
'straight_cables': queryset.straight_cables,
|
||||
'draw_termination_labels': queryset.draw_termination_labels,
|
||||
'draw_cable_labels': queryset.draw_cable_labels,
|
||||
'grid_size': queryset.grid_size,
|
||||
'node_label_items': tuple(queryset.node_label_items.translate({ord(i): None for i in '[]\''}).split(', ')),
|
||||
},
|
||||
|
||||
@@ -7,8 +7,8 @@ long_description = readme.read_text()
|
||||
|
||||
setup(
|
||||
name="netbox-topology-views",
|
||||
version="4.4.0",
|
||||
description="An NetBox plugin to create Topology maps",
|
||||
version="4.5.2",
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user