245 lines
8.8 KiB
Python
245 lines
8.8 KiB
Python
import logging
|
|
import re
|
|
from html import unescape
|
|
|
|
from django.apps import apps
|
|
from django.core.exceptions import ObjectDoesNotExist
|
|
from django.db import OperationalError, ProgrammingError
|
|
from django.templatetags.static import static
|
|
from django.utils.html import escape, json_script
|
|
from netbox.plugins import get_plugin_config
|
|
|
|
from . import __version__
|
|
from .rack_width import effective_width_positions
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TOPOLOGY_RACK_ELEVATION_VIEW = "plugins:netbox_topology_views:rack_elevation"
|
|
RACK_DEVICE_TAG_RE = re.compile(
|
|
r"<a\b[^>]*\bclass=[\"'][^\"']*\brack-device\b[^\"']*[\"'][^>]*>",
|
|
re.IGNORECASE,
|
|
)
|
|
HREF_RE = re.compile(r"\bhref=[\"'](?P<url>[^\"']+)[\"']", re.IGNORECASE)
|
|
STYLE_RE = re.compile(r"\bstyle=(?P<quote>[\"'])(?P<style>.*?)(?P=quote)", re.IGNORECASE)
|
|
DEVICE_ID_RE = re.compile(r"(?:^|/)dcim/devices/(?P<device_id>\d+)/?(?:$|[?#])")
|
|
|
|
|
|
def is_topology_rack_elevation_request(request):
|
|
resolver_match = getattr(request, "resolver_match", None)
|
|
return getattr(resolver_match, "view_name", None) == TOPOLOGY_RACK_ELEVATION_VIEW
|
|
|
|
|
|
def topology_rack_width_enabled(request):
|
|
"""Return whether the optional Topology Views rack integration applies."""
|
|
return bool(
|
|
is_topology_rack_elevation_request(request)
|
|
and get_plugin_config("netbox_utilities", "topology_views_rack_width_enabled")
|
|
and apps.is_installed("netbox_topology_views")
|
|
)
|
|
|
|
|
|
def serialize_topology_placement(placement):
|
|
return serialize_topology_width(
|
|
placement.device,
|
|
placement.width,
|
|
placement.horizontal_position,
|
|
source="stored",
|
|
)
|
|
|
|
|
|
def serialize_topology_width(device, width, horizontal_position, *, source):
|
|
width = int(width)
|
|
horizontal_position = int(horizontal_position)
|
|
return {
|
|
"device_id": device.pk,
|
|
"url": device.get_absolute_url(),
|
|
"width": width,
|
|
"horizontal_position": horizontal_position,
|
|
"left_percent": round((horizontal_position - 1) / width * 100, 8),
|
|
"width_percent": round(100 / width, 8),
|
|
"width_source": source,
|
|
}
|
|
|
|
|
|
def get_topology_rack_width_data(request):
|
|
"""Return permitted partial-width devices shown by Topology Views."""
|
|
if not topology_rack_width_enabled(request):
|
|
return None
|
|
|
|
data = {"devices": [], "status": "ready", "schema_version": 3, "complete": False}
|
|
if not request.GET:
|
|
return data
|
|
|
|
from dcim.models import Device, Rack
|
|
|
|
try:
|
|
racks = Rack.objects.restrict(request.user, "view")
|
|
selected_racks = request.GET.getlist("rack_id")
|
|
if selected_racks:
|
|
racks = racks.filter(pk__in=selected_racks)
|
|
else:
|
|
selected_sites = request.GET.getlist("site_id")
|
|
selected_locations = request.GET.getlist("location_id")
|
|
if selected_sites:
|
|
racks = racks.filter(site_id__in=selected_sites)
|
|
if selected_locations:
|
|
racks = racks.filter(location_id__in=selected_locations)
|
|
|
|
devices = list(
|
|
Device.objects.restrict(request.user, "view")
|
|
.filter(
|
|
rack_id__in=racks.values("pk"),
|
|
position__gt=0,
|
|
device_type__u_height__gt=0,
|
|
)
|
|
.select_related("device_type", "netbox_utilities_rack_placement")
|
|
.order_by("rack_id", "face", "position", "pk")
|
|
)
|
|
widths = effective_width_positions(devices)
|
|
data["devices"] = [
|
|
serialize_topology_width(device, width, position, source=source)
|
|
for device in devices
|
|
for width, position, source in (widths[device.pk],)
|
|
if width > 1
|
|
]
|
|
data["complete"] = True
|
|
return data
|
|
except (
|
|
AttributeError,
|
|
ObjectDoesNotExist,
|
|
OperationalError,
|
|
ProgrammingError,
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
# Keep NetBox usable while plugin migrations are being installed.
|
|
logger.warning(
|
|
"Could not load partial rack widths for NetBox Topology Views path %s; using its native rack layout",
|
|
request.get_full_path(),
|
|
exc_info=True,
|
|
)
|
|
data["status"] = "native-fallback"
|
|
return data
|
|
|
|
|
|
def _visible_topology_device_urls(html):
|
|
"""Return device URLs which the authorized Topology response actually contains."""
|
|
urls = {}
|
|
for tag_match in RACK_DEVICE_TAG_RE.finditer(html):
|
|
href_match = HREF_RE.search(tag_match.group(0))
|
|
if href_match is None:
|
|
continue
|
|
url = unescape(href_match.group("url"))
|
|
device_match = DEVICE_ID_RE.search(url)
|
|
if device_match is not None:
|
|
urls[int(device_match.group("device_id"))] = url
|
|
return urls
|
|
|
|
|
|
def _topology_data_for_visible_devices(visible_urls):
|
|
"""Build widths only for devices already exposed by the foreign view."""
|
|
from dcim.models import Device
|
|
|
|
devices = list(
|
|
Device.objects.filter(pk__in=visible_urls)
|
|
.select_related("device_type", "netbox_utilities_rack_placement")
|
|
.order_by("rack_id", "face", "position", "pk")
|
|
)
|
|
widths = effective_width_positions(devices)
|
|
result = {"devices": [], "status": "ready", "schema_version": 3, "complete": True}
|
|
for device in devices:
|
|
width, position, source = widths[device.pk]
|
|
if width <= 1:
|
|
continue
|
|
descriptor = serialize_topology_width(device, width, position, source=source)
|
|
descriptor["url"] = visible_urls[device.pk]
|
|
result["devices"].append(descriptor)
|
|
return result
|
|
|
|
|
|
def _css_string(value):
|
|
return str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\a ")
|
|
|
|
|
|
def _topology_width_head(data):
|
|
rules = []
|
|
for device in data["devices"]:
|
|
rules.append(
|
|
f'.rack-device[href="{_css_string(device["url"])}"] {{'
|
|
" right: auto !important;"
|
|
f" left: calc({device['left_percent']}% + 3px) !important;"
|
|
f" width: calc({device['width_percent']}% - 6px) !important;"
|
|
" }"
|
|
)
|
|
stylesheet = f'<style id="netbox-utilities-topology-rack-width-styles">{"".join(rules)}</style>'
|
|
payload = json_script(data, "netbox-utilities-topology-rack-width-data")
|
|
script_url = escape(f"{static('netbox_utilities/topology-rack-width.js')}?v={__version__}")
|
|
return f'{stylesheet}{payload}<script src="{script_url}" defer></script>'
|
|
|
|
|
|
def _apply_topology_widths_to_device_tags(html, data):
|
|
descriptors = {device["url"]: device for device in data["devices"]}
|
|
|
|
def replace_tag(match):
|
|
tag = match.group(0)
|
|
href_match = HREF_RE.search(tag)
|
|
if href_match is None:
|
|
return tag
|
|
descriptor = descriptors.get(unescape(href_match.group("url")))
|
|
if descriptor is None:
|
|
return tag
|
|
|
|
geometry = (
|
|
"right:auto!important;"
|
|
f"left:calc({descriptor['left_percent']}% + 3px)!important;"
|
|
f"width:calc({descriptor['width_percent']}% - 6px)!important"
|
|
)
|
|
style_match = STYLE_RE.search(tag)
|
|
if style_match is not None:
|
|
quote = style_match.group("quote")
|
|
existing = style_match.group("style").rstrip().rstrip(";")
|
|
replacement = f"style={quote}{existing};{geometry}{quote}"
|
|
return f"{tag[: style_match.start()]}{replacement}{tag[style_match.end() :]}"
|
|
return f'{tag[:-1]} style="{geometry}">'
|
|
|
|
return RACK_DEVICE_TAG_RE.sub(replace_tag, html)
|
|
|
|
|
|
def _inject_topology_widths(response):
|
|
if (
|
|
getattr(response, "streaming", False)
|
|
or getattr(response, "status_code", None) != 200
|
|
or "text/html" not in response.get("Content-Type", "")
|
|
):
|
|
return response
|
|
|
|
html = response.content.decode(response.charset or "utf-8")
|
|
if "</head>" not in html.lower() or "netbox-utilities-topology-rack-width-data" in html:
|
|
return response
|
|
visible_urls = _visible_topology_device_urls(html)
|
|
data = _topology_data_for_visible_devices(visible_urls)
|
|
html = _apply_topology_widths_to_device_tags(html, data)
|
|
insertion = _topology_width_head(data)
|
|
head_end = html.lower().index("</head>")
|
|
html = f"{html[:head_end]}{insertion}{html[head_end:]}"
|
|
content = html.encode(response.charset or "utf-8")
|
|
response.content = content
|
|
if response.has_header("Content-Length"):
|
|
response["Content-Length"] = str(len(content))
|
|
return response
|
|
|
|
|
|
def apply_topology_rack_widths(request, response):
|
|
"""Apply widths after the optional view has rendered its authorized devices."""
|
|
if not topology_rack_width_enabled(request):
|
|
return response
|
|
try:
|
|
return _inject_topology_widths(response)
|
|
except Exception:
|
|
logger.warning(
|
|
"Could not inject partial rack widths into NetBox Topology Views path %s",
|
|
request.get_full_path(),
|
|
exc_info=True,
|
|
)
|
|
return response
|