netbox-render 0.1.4__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {netbox_render-0.1.4/netbox_render.egg-info → netbox_render-0.2.0}/PKG-INFO +34 -9
- netbox_render-0.1.4/PKG-INFO → netbox_render-0.2.0/README.md +20 -26
- {netbox_render-0.1.4 → netbox_render-0.2.0}/netbox_render/__init__.py +1 -0
- netbox_render-0.2.0/netbox_render/elevation_patch.py +302 -0
- netbox_render-0.2.0/netbox_render/version.py +1 -0
- netbox_render-0.2.0/netbox_render.egg-info/PKG-INFO +140 -0
- {netbox_render-0.1.4 → netbox_render-0.2.0}/netbox_render.egg-info/SOURCES.txt +1 -0
- netbox_render-0.2.0/netbox_render.egg-info/requires.txt +4 -0
- {netbox_render-0.1.4 → netbox_render-0.2.0}/pyproject.toml +20 -1
- netbox_render-0.2.0/tests/test_elevation_patch.py +611 -0
- netbox_render-0.1.4/README.md +0 -96
- netbox_render-0.1.4/netbox_render/elevation_patch.py +0 -148
- netbox_render-0.1.4/netbox_render/version.py +0 -1
- netbox_render-0.1.4/tests/test_elevation_patch.py +0 -319
- {netbox_render-0.1.4 → netbox_render-0.2.0}/LICENSE +0 -0
- {netbox_render-0.1.4 → netbox_render-0.2.0}/netbox_render/version_check.py +0 -0
- {netbox_render-0.1.4 → netbox_render-0.2.0}/netbox_render.egg-info/dependency_links.txt +0 -0
- {netbox_render-0.1.4 → netbox_render-0.2.0}/netbox_render.egg-info/top_level.txt +0 -0
- {netbox_render-0.1.4 → netbox_render-0.2.0}/setup.cfg +0 -0
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: netbox-render
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: NetBox plugin for subdivided device bay rack elevation rendering
|
|
5
5
|
Author-email: Parag Mehta <pmehta@pmehta.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
7
|
-
Project-URL: Homepage, https://
|
|
7
|
+
Project-URL: Homepage, https://pypi.org/project/netbox-render/
|
|
8
|
+
Project-URL: GitHub, https://github.com/paragm/netbox-render
|
|
9
|
+
Project-URL: Repository, https://github.com/paragm/netbox-render.git
|
|
10
|
+
Project-URL: Documentation, https://github.com/paragm/netbox-render/blob/main/docs/shelf-devices-guide.md
|
|
8
11
|
Project-URL: Changelog, https://github.com/paragm/netbox-render/blob/main/CHANGELOG.md
|
|
9
12
|
Project-URL: Issues, https://github.com/paragm/netbox-render/issues
|
|
13
|
+
Keywords: netbox-render,netbox,netbox-plugin,netbox plugin,netbox community plugin
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
15
|
Classifier: Framework :: Django
|
|
16
|
+
Classifier: Framework :: Django :: 5
|
|
17
|
+
Classifier: Intended Audience :: System Administrators
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
11
19
|
Classifier: Programming Language :: Python :: 3
|
|
12
20
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
21
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
22
|
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: System :: Networking
|
|
15
24
|
Requires-Python: >=3.12.0
|
|
16
25
|
Description-Content-Type: text/markdown
|
|
17
26
|
License-File: LICENSE
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff; extra == "dev"
|
|
18
30
|
Dynamic: license-file
|
|
19
31
|
|
|
20
32
|
<p align="center">
|
|
@@ -42,7 +54,7 @@ Devices without bays are untouched.
|
|
|
42
54
|
|
|
43
55
|
| Without plugin | With plugin |
|
|
44
56
|
|:-:|:-:|
|
|
45
|
-
|  |  |
|
|
57
|
+
|  |  |
|
|
46
58
|
| Single opaque block | Subdivided with bay labels and role colors |
|
|
47
59
|
|
|
48
60
|
### Bay ordering
|
|
@@ -53,7 +65,7 @@ Bays sort alphabetically by name — there's no numeric position field in NetBox
|
|
|
53
65
|
|
|
54
66
|
| NetBox | Plugin | Status |
|
|
55
67
|
|--------|--------|--------|
|
|
56
|
-
| 4.6.x | 0.
|
|
68
|
+
| 4.6.x | 0.2.0 | Tested |
|
|
57
69
|
|
|
58
70
|
Startup checks verify the patched method signature hasn't changed. If it has, the plugin raises a `RuntimeError` instead of loading.
|
|
59
71
|
|
|
@@ -79,21 +91,34 @@ For development: `pip install -e .`
|
|
|
79
91
|
|
|
80
92
|
## Configuration
|
|
81
93
|
|
|
82
|
-
One option in `PLUGINS_CONFIG`:
|
|
83
|
-
|
|
84
94
|
```python
|
|
85
95
|
PLUGINS_CONFIG = {
|
|
86
96
|
'netbox_render': {
|
|
87
|
-
'enable_images': True,
|
|
97
|
+
'enable_images': True, # default: False
|
|
98
|
+
'layouts': { # default: {} (auto-calculate)
|
|
99
|
+
'mac-mini-shelf-2u': {'columns': 3},
|
|
100
|
+
'rpi-cluster-2u': {'columns': 5},
|
|
101
|
+
},
|
|
88
102
|
},
|
|
89
103
|
}
|
|
90
104
|
```
|
|
91
105
|
|
|
92
|
-
|
|
106
|
+
| Key | Default | Description |
|
|
107
|
+
|-----|---------|-------------|
|
|
108
|
+
| `enable_images` | `False` | Show device type front/rear images in bay sections |
|
|
109
|
+
| `layouts` | `{}` | Per-device-type grid layout overrides (key = device type slug) |
|
|
110
|
+
|
|
111
|
+
### Grid layout
|
|
112
|
+
|
|
113
|
+
Dense shelves (many bays in few rack units) automatically switch to a grid layout when vertical stacking would make bays too short. The column count is auto-calculated to produce the most square cells.
|
|
114
|
+
|
|
115
|
+
Override with `layouts` using the device type slug as the key and `{'columns': N}` as the value. Invalid values (zero, non-integer, exceeding bay count) fall back to auto-calculation with a logged warning.
|
|
116
|
+
|
|
117
|
+
See the [shelf devices guide](docs/shelf-devices-guide.md) for detailed examples.
|
|
93
118
|
|
|
94
119
|
## Shelf device guide
|
|
95
120
|
|
|
96
|
-
See [
|
|
121
|
+
See [Shelf-devices Guide](https://raw.githubusercontent.com/paragm/netbox-render/main/docs/shelf-devices-guide.md) for a walkthrough on modeling rack shelves using device bays and how this plugin renders them.
|
|
97
122
|
|
|
98
123
|
## Known limitations
|
|
99
124
|
|
|
@@ -1,22 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: netbox-render
|
|
3
|
-
Version: 0.1.4
|
|
4
|
-
Summary: NetBox plugin for subdivided device bay rack elevation rendering
|
|
5
|
-
Author-email: Parag Mehta <pmehta@pmehta.com>
|
|
6
|
-
License-Expression: Apache-2.0
|
|
7
|
-
Project-URL: Homepage, https://github.com/paragm/netbox-render
|
|
8
|
-
Project-URL: Changelog, https://github.com/paragm/netbox-render/blob/main/CHANGELOG.md
|
|
9
|
-
Project-URL: Issues, https://github.com/paragm/netbox-render/issues
|
|
10
|
-
Classifier: Framework :: Django
|
|
11
|
-
Classifier: Programming Language :: Python :: 3
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
-
Requires-Python: >=3.12.0
|
|
16
|
-
Description-Content-Type: text/markdown
|
|
17
|
-
License-File: LICENSE
|
|
18
|
-
Dynamic: license-file
|
|
19
|
-
|
|
20
1
|
<p align="center">
|
|
21
2
|
<img src="https://raw.githubusercontent.com/paragm/netbox-render/main/docs/images/icon.svg" alt="netbox-render" width="128" height="128">
|
|
22
3
|
</p>
|
|
@@ -42,7 +23,7 @@ Devices without bays are untouched.
|
|
|
42
23
|
|
|
43
24
|
| Without plugin | With plugin |
|
|
44
25
|
|:-:|:-:|
|
|
45
|
-
|  |  |
|
|
26
|
+
|  |  |
|
|
46
27
|
| Single opaque block | Subdivided with bay labels and role colors |
|
|
47
28
|
|
|
48
29
|
### Bay ordering
|
|
@@ -53,7 +34,7 @@ Bays sort alphabetically by name — there's no numeric position field in NetBox
|
|
|
53
34
|
|
|
54
35
|
| NetBox | Plugin | Status |
|
|
55
36
|
|--------|--------|--------|
|
|
56
|
-
| 4.6.x | 0.
|
|
37
|
+
| 4.6.x | 0.2.0 | Tested |
|
|
57
38
|
|
|
58
39
|
Startup checks verify the patched method signature hasn't changed. If it has, the plugin raises a `RuntimeError` instead of loading.
|
|
59
40
|
|
|
@@ -79,21 +60,34 @@ For development: `pip install -e .`
|
|
|
79
60
|
|
|
80
61
|
## Configuration
|
|
81
62
|
|
|
82
|
-
One option in `PLUGINS_CONFIG`:
|
|
83
|
-
|
|
84
63
|
```python
|
|
85
64
|
PLUGINS_CONFIG = {
|
|
86
65
|
'netbox_render': {
|
|
87
|
-
'enable_images': True,
|
|
66
|
+
'enable_images': True, # default: False
|
|
67
|
+
'layouts': { # default: {} (auto-calculate)
|
|
68
|
+
'mac-mini-shelf-2u': {'columns': 3},
|
|
69
|
+
'rpi-cluster-2u': {'columns': 5},
|
|
70
|
+
},
|
|
88
71
|
},
|
|
89
72
|
}
|
|
90
73
|
```
|
|
91
74
|
|
|
92
|
-
|
|
75
|
+
| Key | Default | Description |
|
|
76
|
+
|-----|---------|-------------|
|
|
77
|
+
| `enable_images` | `False` | Show device type front/rear images in bay sections |
|
|
78
|
+
| `layouts` | `{}` | Per-device-type grid layout overrides (key = device type slug) |
|
|
79
|
+
|
|
80
|
+
### Grid layout
|
|
81
|
+
|
|
82
|
+
Dense shelves (many bays in few rack units) automatically switch to a grid layout when vertical stacking would make bays too short. The column count is auto-calculated to produce the most square cells.
|
|
83
|
+
|
|
84
|
+
Override with `layouts` using the device type slug as the key and `{'columns': N}` as the value. Invalid values (zero, non-integer, exceeding bay count) fall back to auto-calculation with a logged warning.
|
|
85
|
+
|
|
86
|
+
See the [shelf devices guide](docs/shelf-devices-guide.md) for detailed examples.
|
|
93
87
|
|
|
94
88
|
## Shelf device guide
|
|
95
89
|
|
|
96
|
-
See [
|
|
90
|
+
See [Shelf-devices Guide](https://raw.githubusercontent.com/paragm/netbox-render/main/docs/shelf-devices-guide.md) for a walkthrough on modeling rack shelves using device bays and how this plugin renders them.
|
|
97
91
|
|
|
98
92
|
## Known limitations
|
|
99
93
|
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from svgwrite.container import Hyperlink
|
|
6
|
+
from svgwrite.masking import ClipPath
|
|
7
|
+
from svgwrite.shapes import Line, Rect
|
|
8
|
+
from svgwrite.text import Text
|
|
9
|
+
|
|
10
|
+
from dcim.svg.racks import RackElevationSVG, get_device_description
|
|
11
|
+
from utilities.html import foreground_color
|
|
12
|
+
|
|
13
|
+
from .version_check import verify_patch_target
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger('netbox_render')
|
|
16
|
+
|
|
17
|
+
_original_draw_device = None
|
|
18
|
+
|
|
19
|
+
MIN_CELL_HEIGHT = 8
|
|
20
|
+
MIN_CELL_WIDTH = 30
|
|
21
|
+
LABEL_FONT_SIZE = 13
|
|
22
|
+
CHAR_WIDTH_RATIO = 0.55
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_plugin_setting(key, default=None):
|
|
26
|
+
return settings.PLUGINS_CONFIG.get('netbox_render', {}).get(key, default)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cell_font_size(cell_width: float) -> int:
|
|
30
|
+
if cell_width >= 100:
|
|
31
|
+
return LABEL_FONT_SIZE
|
|
32
|
+
return max(9, min(LABEL_FONT_SIZE, int(cell_width * 0.16)))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _wrap_label(label: str, cell_width: float, font_size: int) -> list[str]:
|
|
36
|
+
char_width = font_size * CHAR_WIDTH_RATIO
|
|
37
|
+
max_chars = max(2, int(cell_width / char_width))
|
|
38
|
+
if len(label) <= max_chars:
|
|
39
|
+
return [label]
|
|
40
|
+
result = []
|
|
41
|
+
remaining = label
|
|
42
|
+
while remaining:
|
|
43
|
+
if len(remaining) <= max_chars:
|
|
44
|
+
result.append(remaining)
|
|
45
|
+
break
|
|
46
|
+
break_at = max_chars
|
|
47
|
+
for i in range(max_chars, 0, -1):
|
|
48
|
+
if remaining[i - 1] in ' -_':
|
|
49
|
+
break_at = i
|
|
50
|
+
break
|
|
51
|
+
result.append(remaining[:break_at])
|
|
52
|
+
remaining = remaining[break_at:].lstrip()
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _optimal_columns(n_bays, width, height):
|
|
57
|
+
best = (float('inf'), n_bays, 0)
|
|
58
|
+
best_cols = 1
|
|
59
|
+
for cols in range(1, n_bays + 1):
|
|
60
|
+
cell_w = width / cols
|
|
61
|
+
if cell_w < MIN_CELL_WIDTH:
|
|
62
|
+
continue
|
|
63
|
+
rows = math.ceil(n_bays / cols)
|
|
64
|
+
cell_h = height / rows
|
|
65
|
+
if cell_h < MIN_CELL_HEIGHT:
|
|
66
|
+
continue
|
|
67
|
+
ratio = max(cell_w, cell_h) / min(cell_w, cell_h)
|
|
68
|
+
empty = (cols * rows) - n_bays
|
|
69
|
+
key = (ratio + empty * 0.5, empty, -cols)
|
|
70
|
+
if key < best:
|
|
71
|
+
best = key
|
|
72
|
+
best_cols = cols
|
|
73
|
+
return best_cols
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve_columns(device_type_slug, n_bays, width, height):
|
|
77
|
+
layouts = _get_plugin_setting('layouts', {})
|
|
78
|
+
if isinstance(layouts, dict) and device_type_slug in layouts:
|
|
79
|
+
try:
|
|
80
|
+
cols = int(layouts[device_type_slug].get('columns', 1))
|
|
81
|
+
except (TypeError, ValueError, AttributeError):
|
|
82
|
+
cols = 0
|
|
83
|
+
if 1 <= cols <= n_bays:
|
|
84
|
+
return cols
|
|
85
|
+
logger.warning("netbox_render: invalid columns=%r for %s, falling back to auto", cols, device_type_slug)
|
|
86
|
+
return _optimal_columns(n_bays, width, height)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _render_wrapped_text(container, lines, text_x, bay_y, cell_height,
|
|
90
|
+
font_size, clip_id, text_color, css_extra,
|
|
91
|
+
stroke_outline=False):
|
|
92
|
+
line_height = font_size * 1.3
|
|
93
|
+
total_text_height = len(lines) * line_height
|
|
94
|
+
font_style = f'font-size:{font_size}px' if font_size != LABEL_FONT_SIZE else None
|
|
95
|
+
padding_bottom = 3
|
|
96
|
+
|
|
97
|
+
if stroke_outline:
|
|
98
|
+
text_start_y = bay_y + cell_height - total_text_height - padding_bottom + font_size * 0.4
|
|
99
|
+
else:
|
|
100
|
+
text_start_y = bay_y + (cell_height - total_text_height) / 2 + font_size * 0.4
|
|
101
|
+
|
|
102
|
+
for j, line_text in enumerate(lines):
|
|
103
|
+
line_y = text_start_y + j * line_height
|
|
104
|
+
base_kwargs = dict(insert=(text_x, line_y))
|
|
105
|
+
if font_style:
|
|
106
|
+
base_kwargs['style'] = font_style
|
|
107
|
+
|
|
108
|
+
if stroke_outline:
|
|
109
|
+
container.add(Text(
|
|
110
|
+
line_text, stroke='black', stroke_width='0.3em',
|
|
111
|
+
stroke_linejoin='round', class_=f'device-image-label{css_extra}',
|
|
112
|
+
**base_kwargs,
|
|
113
|
+
))
|
|
114
|
+
container.add(Text(
|
|
115
|
+
line_text, fill='white',
|
|
116
|
+
class_=f'device-image-label{css_extra}',
|
|
117
|
+
**base_kwargs,
|
|
118
|
+
))
|
|
119
|
+
else:
|
|
120
|
+
container.add(Text(
|
|
121
|
+
line_text, fill=text_color,
|
|
122
|
+
clip_path=f"url(#{clip_id})",
|
|
123
|
+
class_=f'label{css_extra}',
|
|
124
|
+
**base_kwargs,
|
|
125
|
+
))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _patched_draw_device(self, device, coords, size, color=None, image=None):
|
|
129
|
+
try:
|
|
130
|
+
bay_list = list(
|
|
131
|
+
device.devicebays
|
|
132
|
+
.select_related('installed_device__role', 'installed_device__device_type')
|
|
133
|
+
.order_by('name')
|
|
134
|
+
)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
logger.error("netbox_render: failed to query devicebays for %s (pk=%s): %s", device, device.pk, e)
|
|
137
|
+
return _original_draw_device(self, device, coords, size, color=color, image=image)
|
|
138
|
+
|
|
139
|
+
n_bays = len(bay_list)
|
|
140
|
+
logger.debug(
|
|
141
|
+
"netbox_render: device=%s pk=%s device_type_slug=%s devicebays=%d coords=%s size=%s",
|
|
142
|
+
device, device.pk, device.device_type.slug, n_bays, coords, size,
|
|
143
|
+
)
|
|
144
|
+
if n_bays == 0:
|
|
145
|
+
return _original_draw_device(self, device, coords, size, color=color, image=image)
|
|
146
|
+
|
|
147
|
+
enable_images = _get_plugin_setting('enable_images', False)
|
|
148
|
+
x, y = coords
|
|
149
|
+
width, total_height = size
|
|
150
|
+
|
|
151
|
+
columns = _resolve_columns(device.device_type.slug, n_bays, width, total_height)
|
|
152
|
+
rows = math.ceil(n_bays / columns)
|
|
153
|
+
cell_width = width / columns
|
|
154
|
+
cell_height = total_height / rows
|
|
155
|
+
logger.debug(
|
|
156
|
+
"netbox_render: grid=%dx%d cell=%.1fx%.1f (height_per_bay=%.1f, threshold=%d)",
|
|
157
|
+
columns, rows, cell_width, cell_height, total_height / n_bays, MIN_CELL_HEIGHT,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
font_size = _cell_font_size(cell_width)
|
|
161
|
+
|
|
162
|
+
for i, bay in enumerate(bay_list):
|
|
163
|
+
index = i + 1
|
|
164
|
+
child = bay.installed_device
|
|
165
|
+
col = i % columns
|
|
166
|
+
row = i // columns
|
|
167
|
+
bay_x = x + col * cell_width
|
|
168
|
+
bay_y = y + row * cell_height
|
|
169
|
+
bay_coords = (bay_x, bay_y)
|
|
170
|
+
bay_size = (cell_width, cell_height)
|
|
171
|
+
|
|
172
|
+
if child:
|
|
173
|
+
bay_color = child.role.color if color is not None else None
|
|
174
|
+
label = f"{index}: {child.name or str(child.device_type)}"
|
|
175
|
+
description = get_device_description(child)
|
|
176
|
+
device_url = f'{self.base_url}{child.get_absolute_url()}'
|
|
177
|
+
else:
|
|
178
|
+
bay_color = None
|
|
179
|
+
label = f"{index}: (empty)"
|
|
180
|
+
description = f"Bay {bay.name}: empty"
|
|
181
|
+
device_url = None
|
|
182
|
+
|
|
183
|
+
text_color = f'#{foreground_color(bay_color)}' if bay_color else '#000000'
|
|
184
|
+
text_x = bay_x + cell_width / 2
|
|
185
|
+
|
|
186
|
+
is_shaded = self.highlight_devices and (not child or child not in self.highlight_devices)
|
|
187
|
+
css_extra = ' shaded' if is_shaded else ''
|
|
188
|
+
|
|
189
|
+
if cell_height < MIN_CELL_HEIGHT:
|
|
190
|
+
label_lines = [str(index)]
|
|
191
|
+
else:
|
|
192
|
+
label_lines = _wrap_label(label, cell_width, font_size)
|
|
193
|
+
|
|
194
|
+
clip_id = f"clip-bay-{device.pk}-{i}"
|
|
195
|
+
clip_path = ClipPath(id=clip_id)
|
|
196
|
+
clip_path.add(Rect(bay_coords, bay_size))
|
|
197
|
+
self.drawing.defs.add(clip_path)
|
|
198
|
+
|
|
199
|
+
container = self.drawing
|
|
200
|
+
|
|
201
|
+
if device_url:
|
|
202
|
+
link = Hyperlink(href=device_url, target="_parent")
|
|
203
|
+
link.set_desc(description)
|
|
204
|
+
container = link
|
|
205
|
+
|
|
206
|
+
if bay_color:
|
|
207
|
+
container.add(Rect(bay_coords, bay_size, style=f'fill: #{bay_color}', class_=f'slot{css_extra}'))
|
|
208
|
+
else:
|
|
209
|
+
container.add(Rect(bay_coords, bay_size, class_=f'slot blocked{css_extra}'))
|
|
210
|
+
|
|
211
|
+
if row > 0:
|
|
212
|
+
container.add(Line(
|
|
213
|
+
start=(bay_x, bay_y),
|
|
214
|
+
end=(bay_x + cell_width, bay_y),
|
|
215
|
+
stroke='#000000',
|
|
216
|
+
stroke_width=0.5,
|
|
217
|
+
stroke_opacity=0.3,
|
|
218
|
+
))
|
|
219
|
+
if col > 0:
|
|
220
|
+
container.add(Line(
|
|
221
|
+
start=(bay_x, bay_y),
|
|
222
|
+
end=(bay_x, bay_y + cell_height),
|
|
223
|
+
stroke='#000000',
|
|
224
|
+
stroke_width=0.5,
|
|
225
|
+
stroke_opacity=0.3,
|
|
226
|
+
))
|
|
227
|
+
|
|
228
|
+
_render_wrapped_text(
|
|
229
|
+
container, label_lines, text_x, bay_y, cell_height,
|
|
230
|
+
font_size, clip_id, text_color, css_extra,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if child:
|
|
234
|
+
child_dt = child.device_type
|
|
235
|
+
logger.debug(
|
|
236
|
+
"netbox_render: bay=%s child=%s child_device_type=%s (slug=%s) "
|
|
237
|
+
"enable_images=%s include_images=%s front_image=%s rear_image=%s "
|
|
238
|
+
"raw_front='%s' raw_rear='%s'",
|
|
239
|
+
bay.name, child, child_dt, child_dt.slug,
|
|
240
|
+
enable_images, self.include_images,
|
|
241
|
+
bool(child_dt.front_image), bool(child_dt.rear_image),
|
|
242
|
+
getattr(child_dt.front_image, 'name', ''), getattr(child_dt.rear_image, 'name', ''),
|
|
243
|
+
)
|
|
244
|
+
if enable_images and child and self.include_images:
|
|
245
|
+
child_image = child.device_type.front_image if color is not None else child.device_type.rear_image
|
|
246
|
+
if child_image:
|
|
247
|
+
from svgwrite.image import Image
|
|
248
|
+
url = f'{self.base_url}{child_image.url}' if child_image.url.startswith('/') else child_image.url
|
|
249
|
+
img = Image(
|
|
250
|
+
href=url,
|
|
251
|
+
insert=bay_coords,
|
|
252
|
+
size=bay_size,
|
|
253
|
+
class_=f'device-image{css_extra}',
|
|
254
|
+
)
|
|
255
|
+
img.fit(scale='slice')
|
|
256
|
+
container.add(img)
|
|
257
|
+
_render_wrapped_text(
|
|
258
|
+
container, label_lines, text_x, bay_y, cell_height,
|
|
259
|
+
font_size, clip_id, text_color, css_extra,
|
|
260
|
+
stroke_outline=True,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if device_url:
|
|
264
|
+
self.drawing.add(link)
|
|
265
|
+
|
|
266
|
+
total_cells = columns * rows
|
|
267
|
+
for i in range(n_bays, total_cells):
|
|
268
|
+
col = i % columns
|
|
269
|
+
row = i // columns
|
|
270
|
+
bay_x = x + col * cell_width
|
|
271
|
+
bay_y = y + row * cell_height
|
|
272
|
+
self.drawing.add(Rect((bay_x, bay_y), (cell_width, cell_height), class_='slot blocked'))
|
|
273
|
+
if row > 0:
|
|
274
|
+
self.drawing.add(Line(
|
|
275
|
+
start=(bay_x, bay_y),
|
|
276
|
+
end=(bay_x + cell_width, bay_y),
|
|
277
|
+
stroke='#000000',
|
|
278
|
+
stroke_width=0.5,
|
|
279
|
+
stroke_opacity=0.3,
|
|
280
|
+
))
|
|
281
|
+
if col > 0:
|
|
282
|
+
self.drawing.add(Line(
|
|
283
|
+
start=(bay_x, bay_y),
|
|
284
|
+
end=(bay_x, bay_y + cell_height),
|
|
285
|
+
stroke='#000000',
|
|
286
|
+
stroke_width=0.5,
|
|
287
|
+
stroke_opacity=0.3,
|
|
288
|
+
))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def apply_elevation_patch():
|
|
292
|
+
global _original_draw_device
|
|
293
|
+
|
|
294
|
+
if _original_draw_device is not None:
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
verify_patch_target()
|
|
298
|
+
|
|
299
|
+
_original_draw_device = RackElevationSVG._draw_device
|
|
300
|
+
RackElevationSVG._draw_device = _patched_draw_device
|
|
301
|
+
|
|
302
|
+
logger.info("netbox_render: rack elevation patch applied successfully")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: netbox-render
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: NetBox plugin for subdivided device bay rack elevation rendering
|
|
5
|
+
Author-email: Parag Mehta <pmehta@pmehta.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://pypi.org/project/netbox-render/
|
|
8
|
+
Project-URL: GitHub, https://github.com/paragm/netbox-render
|
|
9
|
+
Project-URL: Repository, https://github.com/paragm/netbox-render.git
|
|
10
|
+
Project-URL: Documentation, https://github.com/paragm/netbox-render/blob/main/docs/shelf-devices-guide.md
|
|
11
|
+
Project-URL: Changelog, https://github.com/paragm/netbox-render/blob/main/CHANGELOG.md
|
|
12
|
+
Project-URL: Issues, https://github.com/paragm/netbox-render/issues
|
|
13
|
+
Keywords: netbox-render,netbox,netbox-plugin,netbox plugin,netbox community plugin
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Framework :: Django
|
|
16
|
+
Classifier: Framework :: Django :: 5
|
|
17
|
+
Classifier: Intended Audience :: System Administrators
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: System :: Networking
|
|
24
|
+
Requires-Python: >=3.12.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
<p align="center">
|
|
33
|
+
<img src="https://raw.githubusercontent.com/paragm/netbox-render/main/docs/images/icon.svg" alt="netbox-render" width="128" height="128">
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
# netbox-render
|
|
37
|
+
|
|
38
|
+
A NetBox plugin I wrote to fix a visibility gap in rack elevations: devices with device bays render as a single opaque block, hiding what's actually installed. This plugin subdivides that block into labeled, color-coded, clickable sections — one per bay.
|
|
39
|
+
|
|
40
|
+
## What it does
|
|
41
|
+
|
|
42
|
+
Out of the box, NetBox shows a device-bay device as one rectangle with an occupancy count like "3/4". To see what's installed, you have to click into the parent and check the Device Bays tab.
|
|
43
|
+
|
|
44
|
+
I patched `RackElevationSVG._draw_device` so each bay gets its own section showing:
|
|
45
|
+
|
|
46
|
+
- Bay index (1, 2, 3, ...) sorted by bay name
|
|
47
|
+
- Child device name, or "(empty)"
|
|
48
|
+
- Role color on the front face (rear renders grey, matching stock behavior)
|
|
49
|
+
- Clickable link to the child device page
|
|
50
|
+
|
|
51
|
+
Devices without bays are untouched.
|
|
52
|
+
|
|
53
|
+
### Before and after
|
|
54
|
+
|
|
55
|
+
| Without plugin | With plugin |
|
|
56
|
+
|:-:|:-:|
|
|
57
|
+
|  |  |
|
|
58
|
+
| Single opaque block | Subdivided with bay labels and role colors |
|
|
59
|
+
|
|
60
|
+
### Bay ordering
|
|
61
|
+
|
|
62
|
+
Bays sort alphabetically by name — there's no numeric position field in NetBox's device bay model. Name them "Slot 01", "Slot 02", etc. for predictable ordering.
|
|
63
|
+
|
|
64
|
+
## Compatibility
|
|
65
|
+
|
|
66
|
+
| NetBox | Plugin | Status |
|
|
67
|
+
|--------|--------|--------|
|
|
68
|
+
| 4.6.x | 0.2.0 | Tested |
|
|
69
|
+
|
|
70
|
+
Startup checks verify the patched method signature hasn't changed. If it has, the plugin raises a `RuntimeError` instead of loading.
|
|
71
|
+
|
|
72
|
+
## Installation
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install netbox-render
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Add to `configuration.py`:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
PLUGINS = ['netbox_render']
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Restart NetBox:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
sudo systemctl restart netbox netbox-rq
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
For development: `pip install -e .`
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
PLUGINS_CONFIG = {
|
|
96
|
+
'netbox_render': {
|
|
97
|
+
'enable_images': True, # default: False
|
|
98
|
+
'layouts': { # default: {} (auto-calculate)
|
|
99
|
+
'mac-mini-shelf-2u': {'columns': 3},
|
|
100
|
+
'rpi-cluster-2u': {'columns': 5},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
| Key | Default | Description |
|
|
107
|
+
|-----|---------|-------------|
|
|
108
|
+
| `enable_images` | `False` | Show device type front/rear images in bay sections |
|
|
109
|
+
| `layouts` | `{}` | Per-device-type grid layout overrides (key = device type slug) |
|
|
110
|
+
|
|
111
|
+
### Grid layout
|
|
112
|
+
|
|
113
|
+
Dense shelves (many bays in few rack units) automatically switch to a grid layout when vertical stacking would make bays too short. The column count is auto-calculated to produce the most square cells.
|
|
114
|
+
|
|
115
|
+
Override with `layouts` using the device type slug as the key and `{'columns': N}` as the value. Invalid values (zero, non-integer, exceeding bay count) fall back to auto-calculation with a logged warning.
|
|
116
|
+
|
|
117
|
+
See the [shelf devices guide](docs/shelf-devices-guide.md) for detailed examples.
|
|
118
|
+
|
|
119
|
+
## Shelf device guide
|
|
120
|
+
|
|
121
|
+
See [Shelf-devices Guide](https://raw.githubusercontent.com/paragm/netbox-render/main/docs/shelf-devices-guide.md) for a walkthrough on modeling rack shelves using device bays and how this plugin renders them.
|
|
122
|
+
|
|
123
|
+
## Known limitations
|
|
124
|
+
|
|
125
|
+
- Bay ordering is name-based, not positional — no way around this without modifying NetBox's data model
|
|
126
|
+
- Images off by default
|
|
127
|
+
- Rear face always renders grey (matches stock NetBox)
|
|
128
|
+
- Both faces show the same bay breakdown
|
|
129
|
+
- No migrations, no DB changes — rendering only
|
|
130
|
+
- Monkeypatch approach: I patch an internal method, so test after every NetBox upgrade
|
|
131
|
+
|
|
132
|
+
## Support
|
|
133
|
+
|
|
134
|
+
- **Bugs / feature requests**: [GitHub Issues](https://github.com/paragm/netbox-render/issues)
|
|
135
|
+
- **Questions**: [GitHub Discussions](https://github.com/paragm/netbox-render/discussions)
|
|
136
|
+
- **Security**: [Security Advisories](https://github.com/paragm/netbox-render/security/advisories/new) (not public issues)
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
Apache-2.0
|