netbox-blade-chassis 0.1.2__py3-none-any.whl
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_blade_chassis/__init__.py +24 -0
- netbox_blade_chassis/api/__init__.py +0 -0
- netbox_blade_chassis/api/urls.py +9 -0
- netbox_blade_chassis/api/views.py +60 -0
- netbox_blade_chassis/forms.py +111 -0
- netbox_blade_chassis/layout.py +79 -0
- netbox_blade_chassis/migrations/0001_initial.py +28 -0
- netbox_blade_chassis/migrations/__init__.py +0 -0
- netbox_blade_chassis/models.py +53 -0
- netbox_blade_chassis/svg/__init__.py +0 -0
- netbox_blade_chassis/svg/racks.py +269 -0
- netbox_blade_chassis/template_content.py +14 -0
- netbox_blade_chassis/templates/netbox_blade_chassis/devicetype_blade_layout.html +86 -0
- netbox_blade_chassis/templates/netbox_blade_chassis/inc/elevation_patch.html +33 -0
- netbox_blade_chassis/tests/__init__.py +1 -0
- netbox_blade_chassis/tests/test_forms.py +72 -0
- netbox_blade_chassis/tests/test_layout.py +147 -0
- netbox_blade_chassis/version.py +1 -0
- netbox_blade_chassis/views.py +161 -0
- netbox_blade_chassis-0.1.2.dist-info/METADATA +96 -0
- netbox_blade_chassis-0.1.2.dist-info/RECORD +24 -0
- netbox_blade_chassis-0.1.2.dist-info/WHEEL +5 -0
- netbox_blade_chassis-0.1.2.dist-info/licenses/LICENSE +190 -0
- netbox_blade_chassis-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from netbox.plugins import PluginConfig
|
|
2
|
+
|
|
3
|
+
from .version import __version__
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NetboxBladeChassisConfig(PluginConfig):
|
|
7
|
+
name = 'netbox_blade_chassis'
|
|
8
|
+
verbose_name = 'NetBox Blade Chassis'
|
|
9
|
+
description = 'Visualize blade server bays inside chassis devices in rack elevations.'
|
|
10
|
+
version = __version__
|
|
11
|
+
base_url = 'blade-chassis'
|
|
12
|
+
min_version = '4.6.0'
|
|
13
|
+
max_version = '4.7.99'
|
|
14
|
+
|
|
15
|
+
default_settings = {
|
|
16
|
+
'enable_inline_elevation': True,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
def ready(self):
|
|
20
|
+
super().ready()
|
|
21
|
+
from . import views # noqa: F401 — registers plugin views via decorators
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
config = NetboxBladeChassisConfig
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from django.http import HttpResponse
|
|
2
|
+
from django.shortcuts import get_object_or_404
|
|
3
|
+
from rest_framework.response import Response
|
|
4
|
+
from rest_framework.views import APIView
|
|
5
|
+
|
|
6
|
+
from dcim.api.serializers_.racks import RackElevationDetailFilterSerializer
|
|
7
|
+
from dcim.api.serializers_.rackunits import RackUnitSerializer
|
|
8
|
+
from dcim.models import Rack
|
|
9
|
+
|
|
10
|
+
from netbox_blade_chassis.svg.racks import BladeChassisRackElevationSVG
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RackElevationAPIView(APIView):
|
|
14
|
+
queryset = Rack.objects.all()
|
|
15
|
+
|
|
16
|
+
def get(self, request, pk):
|
|
17
|
+
rack = get_object_or_404(Rack.objects.restrict(request.user, 'view'), pk=pk)
|
|
18
|
+
serializer = RackElevationDetailFilterSerializer(data=request.GET)
|
|
19
|
+
if not serializer.is_valid():
|
|
20
|
+
return Response(serializer.errors, 400)
|
|
21
|
+
|
|
22
|
+
data = serializer.validated_data
|
|
23
|
+
|
|
24
|
+
if data['render'] == 'svg':
|
|
25
|
+
highlight_params = []
|
|
26
|
+
for param in request.GET.getlist('highlight'):
|
|
27
|
+
try:
|
|
28
|
+
highlight_params.append(param.split(':', 1))
|
|
29
|
+
except ValueError:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
drawing = BladeChassisRackElevationSVG(
|
|
33
|
+
rack,
|
|
34
|
+
user=request.user,
|
|
35
|
+
unit_width=data['unit_width'],
|
|
36
|
+
unit_height=data['unit_height'],
|
|
37
|
+
legend_width=data['legend_width'],
|
|
38
|
+
margin_width=data['margin_width'],
|
|
39
|
+
include_images=data['include_images'],
|
|
40
|
+
base_url=request.build_absolute_uri('/'),
|
|
41
|
+
highlight_params=highlight_params,
|
|
42
|
+
)
|
|
43
|
+
return HttpResponse(drawing.render(data['face']).tostring(), content_type='image/svg+xml')
|
|
44
|
+
|
|
45
|
+
elevation = rack.get_rack_units(
|
|
46
|
+
face=data['face'],
|
|
47
|
+
user=request.user,
|
|
48
|
+
exclude=data['exclude'],
|
|
49
|
+
expand_devices=data['expand_devices'],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if q := data['q']:
|
|
53
|
+
q = q.lower()
|
|
54
|
+
elevation = [
|
|
55
|
+
unit for unit in elevation
|
|
56
|
+
if q in str(unit['id']) or q in str(unit['name']).lower()
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
rack_units = RackUnitSerializer(elevation, many=True, context={'request': request})
|
|
60
|
+
return Response(rack_units.data)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from django import forms
|
|
2
|
+
from django.utils.translation import gettext_lazy as _
|
|
3
|
+
|
|
4
|
+
from dcim.forms.model_forms import DeviceBayTemplateForm as BaseDeviceBayTemplateForm
|
|
5
|
+
from dcim.forms.object_create import ComponentCreateForm
|
|
6
|
+
from utilities.forms.fields import ExpandableNameField
|
|
7
|
+
from utilities.forms.rendering import FieldSet
|
|
8
|
+
|
|
9
|
+
from netbox_blade_chassis.models import DeviceBayTemplateLayout
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _parse_position(value):
|
|
13
|
+
if value in (None, ''):
|
|
14
|
+
return None
|
|
15
|
+
return int(value)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _layout_requested(position_x, position_y):
|
|
19
|
+
return position_x is not None and position_y is not None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DeviceBayTemplateLayoutBulkForm(forms.ModelForm):
|
|
23
|
+
position_x = forms.IntegerField(
|
|
24
|
+
required=False,
|
|
25
|
+
min_value=0,
|
|
26
|
+
widget=forms.NumberInput(attrs={'class': 'form-control form-control-sm', 'min': 0}),
|
|
27
|
+
)
|
|
28
|
+
position_y = forms.IntegerField(
|
|
29
|
+
required=False,
|
|
30
|
+
min_value=0,
|
|
31
|
+
widget=forms.NumberInput(attrs={'class': 'form-control form-control-sm', 'min': 0}),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
class Meta:
|
|
35
|
+
model = DeviceBayTemplateLayout
|
|
36
|
+
fields = ('position_x', 'position_y')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DeviceBayTemplateForm(BaseDeviceBayTemplateForm):
|
|
40
|
+
class Meta(BaseDeviceBayTemplateForm.Meta):
|
|
41
|
+
fields = BaseDeviceBayTemplateForm.Meta.fields
|
|
42
|
+
|
|
43
|
+
position_x = forms.IntegerField(
|
|
44
|
+
label=_('Position X'),
|
|
45
|
+
min_value=0,
|
|
46
|
+
required=False,
|
|
47
|
+
help_text=_(
|
|
48
|
+
'Horizontal slot index within the row (0-based). Both Position X and Y are required for elevation display.'
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
position_y = forms.IntegerField(
|
|
52
|
+
label=_('Position Y'),
|
|
53
|
+
min_value=0,
|
|
54
|
+
required=False,
|
|
55
|
+
help_text=_(
|
|
56
|
+
'Row index within the blade grid (0-based). Both Position X and Y are required for elevation display.'
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
fieldsets = (
|
|
61
|
+
FieldSet('device_type', 'name', 'label', 'enabled', 'description', name=_('Template')),
|
|
62
|
+
FieldSet('position_x', 'position_y', name=_('Blade Layout')),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def __init__(self, *args, **kwargs):
|
|
66
|
+
super().__init__(*args, **kwargs)
|
|
67
|
+
if self.instance.pk:
|
|
68
|
+
layout = DeviceBayTemplateLayout.objects.filter(device_bay_template=self.instance).first()
|
|
69
|
+
if layout:
|
|
70
|
+
self.fields['position_x'].initial = layout.position_x
|
|
71
|
+
self.fields['position_y'].initial = layout.position_y
|
|
72
|
+
|
|
73
|
+
def save(self, commit=True):
|
|
74
|
+
instance = super().save(commit)
|
|
75
|
+
position_x = _parse_position(self.cleaned_data.get('position_x'))
|
|
76
|
+
position_y = _parse_position(self.cleaned_data.get('position_y'))
|
|
77
|
+
|
|
78
|
+
if not _layout_requested(position_x, position_y):
|
|
79
|
+
DeviceBayTemplateLayout.objects.filter(device_bay_template=instance).delete()
|
|
80
|
+
return instance
|
|
81
|
+
|
|
82
|
+
DeviceBayTemplateLayout.objects.update_or_create(
|
|
83
|
+
device_bay_template=instance,
|
|
84
|
+
defaults={
|
|
85
|
+
'position_x': position_x,
|
|
86
|
+
'position_y': position_y,
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
return instance
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class DeviceBayTemplateCreateForm(ComponentCreateForm, DeviceBayTemplateForm):
|
|
93
|
+
position_x = ExpandableNameField(
|
|
94
|
+
label=_('Position X'),
|
|
95
|
+
required=False,
|
|
96
|
+
help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)'),
|
|
97
|
+
)
|
|
98
|
+
position_y = ExpandableNameField(
|
|
99
|
+
label=_('Position Y'),
|
|
100
|
+
required=False,
|
|
101
|
+
help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)'),
|
|
102
|
+
)
|
|
103
|
+
replication_fields = ('name', 'label', 'position_x', 'position_y')
|
|
104
|
+
|
|
105
|
+
fieldsets = (
|
|
106
|
+
FieldSet('device_type', 'name', 'label', 'enabled', 'description', name=_('Template')),
|
|
107
|
+
FieldSet('position_x', 'position_y', name=_('Blade Layout')),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
class Meta(DeviceBayTemplateForm.Meta):
|
|
111
|
+
exclude = ('name', 'label', 'position_x', 'position_y')
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from typing import NamedTuple
|
|
3
|
+
|
|
4
|
+
from django.core.exceptions import ValidationError
|
|
5
|
+
from django.utils.translation import gettext_lazy as _
|
|
6
|
+
|
|
7
|
+
from dcim.models import Device, DeviceType
|
|
8
|
+
|
|
9
|
+
from netbox_blade_chassis.models import DeviceBayTemplateLayout
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LayoutEntry(NamedTuple):
|
|
13
|
+
position_x: int
|
|
14
|
+
position_y: int
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_layout_map(device_type: DeviceType) -> dict[str, LayoutEntry]:
|
|
18
|
+
layouts = DeviceBayTemplateLayout.objects.filter(
|
|
19
|
+
device_bay_template__device_type=device_type,
|
|
20
|
+
).select_related('device_bay_template')
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
layout.device_bay_template.name: LayoutEntry(
|
|
24
|
+
layout.position_x,
|
|
25
|
+
layout.position_y,
|
|
26
|
+
)
|
|
27
|
+
for layout in layouts
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def device_has_blade_layout(device_type: DeviceType) -> bool:
|
|
32
|
+
return DeviceBayTemplateLayout.objects.filter(
|
|
33
|
+
device_bay_template__device_type=device_type,
|
|
34
|
+
).exists()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def group_layout_by_row(
|
|
38
|
+
layout_map: dict[str, LayoutEntry],
|
|
39
|
+
) -> dict[int, list[tuple[int, str]]]:
|
|
40
|
+
rows: dict[int, list[tuple[int, str]]] = defaultdict(list)
|
|
41
|
+
for name, entry in layout_map.items():
|
|
42
|
+
rows[entry.position_y].append((entry.position_x, name))
|
|
43
|
+
for row in rows.values():
|
|
44
|
+
row.sort(key=lambda item: item[0])
|
|
45
|
+
return dict(rows)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_symmetric_column_count(layout_map: dict[int, list[tuple[int, str]]]) -> int:
|
|
49
|
+
if not layout_map:
|
|
50
|
+
return 0
|
|
51
|
+
return max(max(position_x for position_x, _ in cells) + 1 for cells in layout_map.values())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def mirror_column_index(position_x: int, column_count: int) -> int:
|
|
55
|
+
return column_count - 1 - position_x
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_grid_symmetry(layout_map: dict[str, LayoutEntry]) -> None:
|
|
59
|
+
rows: dict[int, set[int]] = defaultdict(set)
|
|
60
|
+
for entry in layout_map.values():
|
|
61
|
+
rows[entry.position_y].add(entry.position_x)
|
|
62
|
+
|
|
63
|
+
if not rows:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
row_column_counts = [max(columns) + 1 for columns in rows.values()]
|
|
67
|
+
if len(set(row_column_counts)) > 1:
|
|
68
|
+
raise ValidationError(_('All blade rows must have the same number of columns.'))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_chassis_title(device: Device) -> str:
|
|
72
|
+
return device.name or device.device_type.model
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_blade_label(device: Device) -> str:
|
|
76
|
+
name = device.name or str(device.device_type)
|
|
77
|
+
if '#' in name:
|
|
78
|
+
return name.split('#', 1)[0]
|
|
79
|
+
return name
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
import django.db.models.deletion
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Migration(migrations.Migration):
|
|
6
|
+
|
|
7
|
+
initial = True
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
('dcim', '0237_module_remove_local_context_data'),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.CreateModel(
|
|
15
|
+
name='DeviceBayTemplateLayout',
|
|
16
|
+
fields=[
|
|
17
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
18
|
+
('position_x', models.PositiveIntegerField(default=0, help_text='Horizontal slot index within the row (0-based, left to right).', verbose_name='position X')),
|
|
19
|
+
('position_y', models.PositiveIntegerField(default=0, help_text='Row index within the chassis (0-based, top to bottom).', verbose_name='position Y')),
|
|
20
|
+
('device_bay_template', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='blade_layout', to='dcim.devicebaytemplate')),
|
|
21
|
+
],
|
|
22
|
+
options={
|
|
23
|
+
'verbose_name': 'device bay template layout',
|
|
24
|
+
'verbose_name_plural': 'device bay template layouts',
|
|
25
|
+
'ordering': ('position_y', 'position_x'),
|
|
26
|
+
},
|
|
27
|
+
),
|
|
28
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from django.core.exceptions import ValidationError
|
|
2
|
+
from django.db import models
|
|
3
|
+
from django.utils.translation import gettext_lazy as _
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DeviceBayTemplateLayout(models.Model):
|
|
7
|
+
device_bay_template = models.OneToOneField(
|
|
8
|
+
to='dcim.DeviceBayTemplate',
|
|
9
|
+
on_delete=models.CASCADE,
|
|
10
|
+
related_name='blade_layout',
|
|
11
|
+
)
|
|
12
|
+
position_x = models.PositiveIntegerField(
|
|
13
|
+
verbose_name=_('position X'),
|
|
14
|
+
default=0,
|
|
15
|
+
help_text=_('Horizontal slot index within the row (0-based, left to right).'),
|
|
16
|
+
)
|
|
17
|
+
position_y = models.PositiveIntegerField(
|
|
18
|
+
verbose_name=_('position Y'),
|
|
19
|
+
default=0,
|
|
20
|
+
help_text=_('Row index within the chassis (0-based, top to bottom).'),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
class Meta:
|
|
24
|
+
ordering = ('position_y', 'position_x')
|
|
25
|
+
verbose_name = _('device bay template layout')
|
|
26
|
+
verbose_name_plural = _('device bay template layouts')
|
|
27
|
+
|
|
28
|
+
def __str__(self):
|
|
29
|
+
return f'{self.device_bay_template}: ({self.position_x}, {self.position_y})'
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def device_type(self):
|
|
33
|
+
return self.device_bay_template.device_type
|
|
34
|
+
|
|
35
|
+
def clean(self):
|
|
36
|
+
super().clean()
|
|
37
|
+
|
|
38
|
+
if not hasattr(self, 'device_bay_template'):
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
device_type = self.device_bay_template.device_type
|
|
42
|
+
duplicates = DeviceBayTemplateLayout.objects.filter(
|
|
43
|
+
device_bay_template__device_type=device_type,
|
|
44
|
+
position_x=self.position_x,
|
|
45
|
+
position_y=self.position_y,
|
|
46
|
+
).exclude(pk=self.pk)
|
|
47
|
+
|
|
48
|
+
if duplicates.exists():
|
|
49
|
+
raise ValidationError({
|
|
50
|
+
'position_x': _(
|
|
51
|
+
'Another bay template on this device type already uses position ({x}, {y}).'
|
|
52
|
+
).format(x=self.position_x, y=self.position_y),
|
|
53
|
+
})
|
|
File without changes
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from django.utils.translation import gettext as _
|
|
2
|
+
from svgwrite.container import Hyperlink
|
|
3
|
+
from svgwrite.masking import ClipPath
|
|
4
|
+
from svgwrite.shapes import Line, Rect
|
|
5
|
+
from svgwrite.text import Text, TSpan
|
|
6
|
+
|
|
7
|
+
from dcim.models import Device
|
|
8
|
+
from dcim.svg.racks import RackElevationSVG, truncate_text
|
|
9
|
+
from utilities.html import foreground_color
|
|
10
|
+
|
|
11
|
+
from netbox_blade_chassis.layout import (
|
|
12
|
+
device_has_blade_layout,
|
|
13
|
+
get_blade_label,
|
|
14
|
+
get_chassis_title,
|
|
15
|
+
get_layout_map,
|
|
16
|
+
get_symmetric_column_count,
|
|
17
|
+
group_layout_by_row,
|
|
18
|
+
mirror_column_index,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BladeChassisRackElevationSVG(RackElevationSVG):
|
|
23
|
+
HEADER_HEIGHT = 18
|
|
24
|
+
HEADER_FONT_SIZE = 10
|
|
25
|
+
CELL_FONT_SIZE = 7
|
|
26
|
+
LINE_HEIGHT_RATIO = 1.25
|
|
27
|
+
|
|
28
|
+
BLADE_STYLES = """
|
|
29
|
+
svg .blade-chassis { stroke: none; }
|
|
30
|
+
svg .blade-grid-bg { fill: var(--nbx-rack-slot-bg); stroke: none; }
|
|
31
|
+
svg .blade-cell-empty { fill: color-mix(in srgb, var(--nbx-rack-slot-bg) 65%, var(--nbx-rack-bg)); stroke: none; }
|
|
32
|
+
svg .blade-cell-filled { stroke: none; opacity: 0.92; }
|
|
33
|
+
svg .blade-grid-line { stroke: var(--nbx-rack-slot-border); stroke-width: 1; }
|
|
34
|
+
svg .blade-header-label { font-size: 10px; font-weight: 600; }
|
|
35
|
+
svg .blade-cell-label { font-size: 7px; }
|
|
36
|
+
svg .blade-cell-empty-label { font-size: 8px; fill: var(--nbx-rack-unit-color); opacity: 0.45; }
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, *args, user=None, **kwargs):
|
|
40
|
+
super().__init__(*args, user=user, **kwargs)
|
|
41
|
+
self._user = user
|
|
42
|
+
|
|
43
|
+
def _setup_drawing(self):
|
|
44
|
+
drawing = super()._setup_drawing()
|
|
45
|
+
drawing.defs.add(drawing.style(self.BLADE_STYLES))
|
|
46
|
+
return drawing
|
|
47
|
+
|
|
48
|
+
def _device_is_viewable(self, device):
|
|
49
|
+
if device.pk in self.permitted_device_ids:
|
|
50
|
+
return True
|
|
51
|
+
if self._user is None:
|
|
52
|
+
return True
|
|
53
|
+
return Device.objects.restrict(self._user, 'view').filter(pk=device.pk).exists()
|
|
54
|
+
|
|
55
|
+
def draw_device_front(self, device, coords, size):
|
|
56
|
+
if device_has_blade_layout(device.device_type):
|
|
57
|
+
self._draw_blade_chassis(device, coords, size)
|
|
58
|
+
else:
|
|
59
|
+
super().draw_device_front(device, coords, size)
|
|
60
|
+
|
|
61
|
+
def draw_device_rear(self, device, coords, size):
|
|
62
|
+
if device_has_blade_layout(device.device_type):
|
|
63
|
+
self._draw_blade_chassis(device, coords, size, mirrored=True)
|
|
64
|
+
else:
|
|
65
|
+
super().draw_device_rear(device, coords, size)
|
|
66
|
+
|
|
67
|
+
def _draw_blade_chassis(self, device, coords, size, mirrored=False):
|
|
68
|
+
x, y = coords
|
|
69
|
+
width, height = size
|
|
70
|
+
layout_map = get_layout_map(device.device_type)
|
|
71
|
+
if not layout_map:
|
|
72
|
+
super().draw_device_front(device, coords, size)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
bays_by_name = {bay.name: bay for bay in device.devicebays.all()}
|
|
76
|
+
rows = group_layout_by_row(layout_map)
|
|
77
|
+
row_indices = sorted(rows.keys())
|
|
78
|
+
row_count = len(row_indices)
|
|
79
|
+
column_count = get_symmetric_column_count(rows)
|
|
80
|
+
|
|
81
|
+
header_height = min(self.HEADER_HEIGHT, max(height * 0.18, 12))
|
|
82
|
+
inner_y = y + header_height
|
|
83
|
+
inner_height = max(height - header_height, 1)
|
|
84
|
+
row_height = inner_height / row_count if row_count else inner_height
|
|
85
|
+
column_width = width / column_count if column_count else width
|
|
86
|
+
|
|
87
|
+
color = device.role.color if device.role else None
|
|
88
|
+
header_bg = f'#{color}' if color else 'var(--nbx-rack-slot-bg)'
|
|
89
|
+
text_color = f'#{foreground_color(color)}' if color else 'var(--nbx-rack-unit-color)'
|
|
90
|
+
is_shaded = self.highlight_devices and device not in self.highlight_devices
|
|
91
|
+
css_extra = ' shaded' if is_shaded else ''
|
|
92
|
+
|
|
93
|
+
clip_id = f'blade-clip-{device.pk}'
|
|
94
|
+
clip_path = ClipPath(id=clip_id)
|
|
95
|
+
clip_path.add(Rect((x, inner_y), (width, inner_height)))
|
|
96
|
+
self.drawing.defs.add(clip_path)
|
|
97
|
+
|
|
98
|
+
chassis_link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target='_parent')
|
|
99
|
+
chassis_link.add(Rect(coords, size, style=f'fill: {header_bg}', class_=f'blade-chassis{css_extra}'))
|
|
100
|
+
chassis_link.add(
|
|
101
|
+
Text(
|
|
102
|
+
truncate_text(get_chassis_title(device), width, font_size=self.HEADER_FONT_SIZE),
|
|
103
|
+
insert=(x + width / 2, y + header_height / 2),
|
|
104
|
+
fill=text_color,
|
|
105
|
+
class_=f'blade-header-label{css_extra}',
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
self.drawing.add(chassis_link)
|
|
109
|
+
|
|
110
|
+
self.drawing.add(Rect((x, inner_y), (width, inner_height), class_=f'blade-grid-bg{css_extra}'))
|
|
111
|
+
|
|
112
|
+
for row_number, row_index in enumerate(row_indices):
|
|
113
|
+
cell_y = inner_y + row_number * row_height
|
|
114
|
+
cells_by_x = dict(rows[row_index])
|
|
115
|
+
|
|
116
|
+
for position_x in range(column_count):
|
|
117
|
+
bay_name = cells_by_x.get(position_x)
|
|
118
|
+
bay = bays_by_name.get(bay_name) if bay_name else None
|
|
119
|
+
display_x = mirror_column_index(position_x, column_count) if mirrored else position_x
|
|
120
|
+
cell_x = x + display_x * column_width
|
|
121
|
+
self._draw_blade_cell(
|
|
122
|
+
bay=bay,
|
|
123
|
+
coords=(cell_x, cell_y),
|
|
124
|
+
size=(column_width, row_height),
|
|
125
|
+
css_extra=css_extra,
|
|
126
|
+
clip_id=clip_id,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
self._draw_blade_grid_lines(
|
|
130
|
+
x=x,
|
|
131
|
+
y=inner_y,
|
|
132
|
+
width=width,
|
|
133
|
+
height=inner_height,
|
|
134
|
+
row_count=row_count,
|
|
135
|
+
column_count=column_count,
|
|
136
|
+
css_extra=css_extra,
|
|
137
|
+
)
|
|
138
|
+
self.drawing.add(Line(
|
|
139
|
+
start=(x, inner_y),
|
|
140
|
+
end=(x + width, inner_y),
|
|
141
|
+
class_=f'blade-grid-line{css_extra}',
|
|
142
|
+
))
|
|
143
|
+
|
|
144
|
+
def _draw_blade_grid_lines(self, x, y, width, height, row_count, column_count, css_extra=''):
|
|
145
|
+
row_height = height / row_count if row_count else height
|
|
146
|
+
column_width = width / column_count if column_count else width
|
|
147
|
+
|
|
148
|
+
for column_index in range(1, column_count):
|
|
149
|
+
line_x = x + column_index * column_width
|
|
150
|
+
self.drawing.add(Line(
|
|
151
|
+
start=(line_x, y),
|
|
152
|
+
end=(line_x, y + height),
|
|
153
|
+
class_=f'blade-grid-line{css_extra}',
|
|
154
|
+
))
|
|
155
|
+
|
|
156
|
+
for row_index in range(1, row_count):
|
|
157
|
+
line_y = y + row_index * row_height
|
|
158
|
+
self.drawing.add(Line(
|
|
159
|
+
start=(x, line_y),
|
|
160
|
+
end=(x + width, line_y),
|
|
161
|
+
class_=f'blade-grid-line{css_extra}',
|
|
162
|
+
))
|
|
163
|
+
|
|
164
|
+
def _draw_blade_cell(self, bay, coords, size, css_extra='', clip_id=None):
|
|
165
|
+
cell_x, cell_y = coords
|
|
166
|
+
cell_width, cell_height = size
|
|
167
|
+
clip_attr = {'clip-path': f'url(#{clip_id})'} if clip_id else {}
|
|
168
|
+
text_padding = 2
|
|
169
|
+
text_width = max(cell_width - text_padding * 2, 1)
|
|
170
|
+
text_height = max(cell_height - text_padding * 2, 1)
|
|
171
|
+
|
|
172
|
+
installed = bay.installed_device if bay else None
|
|
173
|
+
if installed and self._device_is_viewable(installed):
|
|
174
|
+
label = get_blade_label(installed)
|
|
175
|
+
description = _('Blade: {name}').format(name=label)
|
|
176
|
+
blade_color = installed.role.color if installed.role else None
|
|
177
|
+
fill_style = f'fill: #{blade_color}' if blade_color else None
|
|
178
|
+
label_color = f'#{foreground_color(blade_color)}' if blade_color else 'var(--nbx-rack-unit-color)'
|
|
179
|
+
|
|
180
|
+
link = Hyperlink(href=f'{self.base_url}{installed.get_absolute_url()}', target='_parent')
|
|
181
|
+
link.set_desc(description)
|
|
182
|
+
link.add(Rect(coords, size, style=fill_style, class_=f'blade-cell-filled{css_extra}', **clip_attr))
|
|
183
|
+
link.add(self._build_cell_text(
|
|
184
|
+
label,
|
|
185
|
+
center_x=cell_x + cell_width / 2,
|
|
186
|
+
center_y=cell_y + cell_height / 2,
|
|
187
|
+
width=text_width,
|
|
188
|
+
height=text_height,
|
|
189
|
+
fill=label_color,
|
|
190
|
+
css_class=f'blade-cell-label{css_extra}',
|
|
191
|
+
clip_attr=clip_attr,
|
|
192
|
+
))
|
|
193
|
+
self.drawing.add(link)
|
|
194
|
+
else:
|
|
195
|
+
self.drawing.add(Rect(coords, size, class_=f'blade-cell-empty{css_extra}', **clip_attr))
|
|
196
|
+
if cell_height >= 10 and cell_width >= 10:
|
|
197
|
+
self.drawing.add(Text(
|
|
198
|
+
'—',
|
|
199
|
+
insert=(cell_x + cell_width / 2, cell_y + cell_height / 2),
|
|
200
|
+
class_=f'blade-cell-empty-label{css_extra}',
|
|
201
|
+
**clip_attr,
|
|
202
|
+
))
|
|
203
|
+
|
|
204
|
+
def _build_cell_text(self, text, center_x, center_y, width, height, fill, css_class, clip_attr=None):
|
|
205
|
+
lines = self._wrap_text(text, width, height, self.CELL_FONT_SIZE)
|
|
206
|
+
line_height = self.CELL_FONT_SIZE * self.LINE_HEIGHT_RATIO
|
|
207
|
+
block_height = line_height * len(lines)
|
|
208
|
+
start_y = center_y - block_height / 2 + line_height / 2
|
|
209
|
+
|
|
210
|
+
text_elem = Text('', fill=fill, class_=css_class, **(clip_attr or {}))
|
|
211
|
+
text_elem['text-anchor'] = 'middle'
|
|
212
|
+
|
|
213
|
+
for index, line in enumerate(lines):
|
|
214
|
+
if index == 0:
|
|
215
|
+
text_elem.add(TSpan(line, insert=(center_x, start_y)))
|
|
216
|
+
else:
|
|
217
|
+
text_elem.add(TSpan(line, x=[center_x], dy=[f'{self.LINE_HEIGHT_RATIO}em']))
|
|
218
|
+
|
|
219
|
+
return text_elem
|
|
220
|
+
|
|
221
|
+
def _wrap_text(self, text, max_width, max_height, font_size):
|
|
222
|
+
char_width = font_size * 0.55
|
|
223
|
+
max_chars = max(1, int(max_width / char_width))
|
|
224
|
+
max_lines = max(1, int(max_height / (font_size * self.LINE_HEIGHT_RATIO)))
|
|
225
|
+
|
|
226
|
+
chunks = []
|
|
227
|
+
for part in text.split('.'):
|
|
228
|
+
if chunks:
|
|
229
|
+
chunks.append('.')
|
|
230
|
+
if part:
|
|
231
|
+
chunks.append(part)
|
|
232
|
+
|
|
233
|
+
lines = []
|
|
234
|
+
current = ''
|
|
235
|
+
for chunk in chunks:
|
|
236
|
+
candidate = current + chunk
|
|
237
|
+
if len(candidate) <= max_chars:
|
|
238
|
+
current = candidate
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
if current:
|
|
242
|
+
lines.append(current)
|
|
243
|
+
current = ''
|
|
244
|
+
|
|
245
|
+
while chunk and len(lines) < max_lines:
|
|
246
|
+
if len(chunk) <= max_chars:
|
|
247
|
+
current = chunk
|
|
248
|
+
chunk = ''
|
|
249
|
+
break
|
|
250
|
+
lines.append(chunk[:max_chars])
|
|
251
|
+
chunk = chunk[max_chars:]
|
|
252
|
+
|
|
253
|
+
if current and len(lines) < max_lines:
|
|
254
|
+
lines.append(current)
|
|
255
|
+
|
|
256
|
+
if not lines:
|
|
257
|
+
lines = [truncate_text(text, max_width, font_size=font_size)]
|
|
258
|
+
elif len(lines) > max_lines:
|
|
259
|
+
lines = lines[:max_lines]
|
|
260
|
+
|
|
261
|
+
joined_length = sum(len(line) for line in lines)
|
|
262
|
+
if joined_length < len(text) and lines:
|
|
263
|
+
last = lines[-1]
|
|
264
|
+
if len(last) > 3:
|
|
265
|
+
lines[-1] = last[: max(1, max_chars - 1)] + '…'
|
|
266
|
+
else:
|
|
267
|
+
lines[-1] = '…'
|
|
268
|
+
|
|
269
|
+
return lines
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from netbox.plugins import PluginTemplateExtension
|
|
2
|
+
from netbox.plugins.utils import get_plugin_config
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ElevationPatchExtension(PluginTemplateExtension):
|
|
6
|
+
def head(self):
|
|
7
|
+
if not get_plugin_config('netbox_blade_chassis', 'enable_inline_elevation', True):
|
|
8
|
+
return ''
|
|
9
|
+
return self.render('netbox_blade_chassis/inc/elevation_patch.html')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
template_extensions = [
|
|
13
|
+
ElevationPatchExtension,
|
|
14
|
+
]
|