Add images/labels toggle, clean up label styling, merge real racks

- Replace the device-image label background bar and text-shadow with
  NetBox's own technique: a black-stroke pass behind a white-fill pass
  of the same text, giving crisp readable labels over any image
  without a dark box behind them.
- Add an "Images and labels / Images only / Labels only" selector
  above the elevation, mirroring the equivalent control on real NetBox
  rack elevations. The plain label is always in the markup; the
  selector only toggles the image and its over-image label, exactly
  like core NetBox does it. Choice persists per browser via
  localStorage and works without edit permissions.
- Add a "Merge into concept" button to the real dcim Rack list: select
  two racks via the existing bulk-select checkboxes and it forwards
  them to the concept-merge page, pre-filled. New
  deploy.merge_racks_to_concept() combines two real racks' devices
  directly into one new, stacked blueprint without an intermediate
  per-rack copy step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 09:50:23 +02:00
co-authored by Claude Sonnet 5
parent 777ded3900
commit e4817ea83b
13 changed files with 430 additions and 39 deletions
@@ -32,33 +32,38 @@
data-u-height="{{ block.device.effective_u_height }}"
data-width-fraction="{{ block.device.width_fraction }}">
<a href="{{ block.device.get_absolute_url }}">
{% if block.image %}
<clipPath id="concept-block-{{ elevation.face }}-{{ block.device.pk }}">
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="{{ block.height }}" rx="2"></rect>
</clipPath>
{% endif %}
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="{{ block.height }}"
rx="2" fill="#{{ block.color }}"
fill-opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
class="device-rect"></rect>
{% comment %}
The plain label is always in the markup, image or not - toggling "images
only" just layers the (opaque) image on top of it, exactly like NetBox's
own rack elevation does. That way there is one code path, not two.
{% endcomment %}
{% if block.show_text %}
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
fill="#{{ block.text_color }}" class="device-label">
{{ block.label }}{% if block.opposite %} <tspan class="opposite-hint">({% trans "rear" %})</tspan>{% endif %}
</text>
{% endif %}
{% if block.image %}
<clipPath id="concept-block-{{ elevation.face }}-{{ block.device.pk }}">
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="{{ block.height }}" rx="2"></rect>
</clipPath>
<image href="{{ block.image.url }}" x="{{ block.x }}" y="{{ block.y }}"
width="{{ block.width }}" height="{{ block.height }}"
preserveAspectRatio="xMidYMid slice"
clip-path="url(#concept-block-{{ elevation.face }}-{{ block.device.pk }})"
opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
class="device-image"></image>
{% endif %}
{% if block.show_text %}
{% if block.image %}
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="16"
class="device-label-bg"></rect>
{% if block.show_text %}
{# Outlined white-on-black text, drawn as a stroke pass then a fill pass, so it reads over any image without a background box. #}
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
class="device-image-label device-image-label-stroke">{{ block.label }}</text>
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
class="device-image-label device-image-label-fill">{{ block.label }}</text>
{% endif %}
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
fill="{% if block.image %}#ffffff{% else %}#{{ block.text_color }}{% endif %}"
class="device-label {% if block.image %}on-image{% endif %}">
{{ block.label }}{% if block.opposite %} <tspan class="opposite-hint">({% trans "rear" %})</tspan>{% endif %}
</text>
{% endif %}
<title>{{ block.label }}{% if block.sublabel %} &middot; {{ block.sublabel }}{% endif %} &middot; U{{ block.device.position }}</title>
</a>
@@ -0,0 +1,8 @@
{% load i18n %}
{% load static %}
<a href="{% url 'plugins:netbox_rack_concept:racks_merge' %}" class="btn btn-outline-secondary"
id="concept-merge-racks-button" data-merge-url="{% url 'plugins:netbox_rack_concept:racks_merge' %}"
title="{% trans 'Select two racks below, then click this to combine them into one concept' %}">
<i class="mdi mdi-call-merge"></i> {% trans "Merge into concept" %}
</a>
<script src="{% static 'netbox_rack_concept/rack_list_merge.js' %}"></script>
@@ -128,12 +128,20 @@
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">
{% trans "Elevation" %}
{% if perms.netbox_rack_concept.change_conceptdevice %}
<span class="badge text-bg-secondary float-end">{% trans "Drag devices to move them" %}</span>
{% endif %}
</h2>
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<h2 class="mb-0">{% trans "Elevation" %}</h2>
<div class="d-flex align-items-center gap-2">
<select id="concept-elevation-view" class="form-select form-select-sm" style="width: auto;"
aria-label="{% trans 'Images and labels' %}">
<option value="images-and-labels">{% trans "Images and labels" %}</option>
<option value="images-only">{% trans "Images only" %}</option>
<option value="labels-only">{% trans "Labels only" %}</option>
</select>
{% if perms.netbox_rack_concept.change_conceptdevice %}
<span class="badge text-bg-secondary">{% trans "Drag devices to move them" %}</span>
{% endif %}
</div>
</div>
<div class="card-body">
<div class="d-flex justify-content-around flex-wrap"
id="concept-elevations"
@@ -0,0 +1,28 @@
{% extends 'base/layout.html' %}
{% load i18n %}
{% load form_helpers %}
{% block title %}{% trans "Merge Racks into a Concept" %}{% endblock %}
{% block content %}
<form action="" method="post">
{% csrf_token %}
<div class="row">
<div class="col col-md-8 offset-md-2">
<div class="card">
<h2 class="card-header">{% trans "Merge two racks into one concept" %}</h2>
<div class="card-body">
<p class="text-muted">
{% trans "Snapshots both racks' devices into one new blueprint - the top rack's devices shifted up by the bottom rack's height. Neither rack is modified." %}
</p>
{% render_form form %}
</div>
<div class="card-footer text-end">
<a href="{{ return_url }}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary">{% trans "Create concept" %}</button>
</div>
</div>
</div>
</div>
</form>
{% endblock %}