Fix rear-face image-only labels, click-to-add units, cable concept devices

- Fix "images only" leaving plain labels visible on faces where a
  device has no image for that side (e.g. no rear_image set): the
  view selector now hides every plain label outright in that mode,
  not just the ones an image happens to sit on top of.
- Remove the "Add planned device" button from the elevation card.
  Empty units are now click targets themselves - clicking one opens
  the add-device form pre-filled with that position and face, exactly
  like a real rack's elevation. Occupancy is computed per unit per
  face from the existing device blocks.
- Add cabling for concept devices: ConceptComponent (interfaces,
  front/rear ports, power ports/outlets, console ports) and
  ConceptCable (a connection between two components, restricted to
  devices in the same concept). components.sync_components()
  populates a device's components from its device type's templates,
  including the front/rear port pass-through mapping copied from
  DeviceType.port_mappings - so a device with front/rear port
  templates (a patch panel being the common case) behaves exactly
  like a real one, without hardcoding anything role-specific.
  Placeholder devices without a device type get components added by
  hand. Full CRUD views, tables, filtersets, API endpoints and a
  "Connect" action wired in from the device detail page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 10:18:50 +02:00
co-authored by Claude Sonnet 5
parent e4817ea83b
commit 63538c2efe
24 changed files with 1293 additions and 33 deletions
@@ -0,0 +1,74 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Cable" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Label" %}</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.get_type_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Color" %}</th>
<td>{% if object.color %}<span class="badge" style="background-color: #{{ object.color }}">&nbsp;&nbsp;&nbsp;</span> #{{ object.color }}{% else %}{{ ''|placeholder }}{% endif %}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/comments.html' %}
</div>
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Side A" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Concept" %}</th>
<td>{{ object.termination_a.device.concept|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.termination_a.device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Component" %}</th>
<td>{{ object.termination_a|linkify }}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Side B" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Concept" %}</th>
<td>{{ object.termination_b.device.concept|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.termination_b.device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Component" %}</th>
<td>{{ object.termination_b|linkify }}</td>
</tr>
</table>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,109 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% block extra_controls %}
{{ block.super }}
{% if not cable and perms.netbox_rack_concept.add_conceptcable %}
<a href="{% url 'plugins:netbox_rack_concept:conceptcable_add' %}?concept={{ object.device.concept_id }}&amp;termination_a_device={{ object.device_id }}&amp;termination_a={{ object.pk }}"
class="btn btn-primary">
<i class="mdi mdi-ethernet-cable"></i> {% trans "Connect" %}
</a>
{% endif %}
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Component" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Concept" %}</th>
<td>{{ object.device.concept|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Label" %}</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Component type" %}</th>
<td>{{ object.get_component_type_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.port_type|placeholder }}</td>
</tr>
{% if object.is_pass_through %}
<tr>
<th scope="row">{% trans "Positions" %}</th>
<td>{{ object.positions }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
{% if object.is_pass_through %}
<div class="card">
<h2 class="card-header">{% trans "Pass-through mapping" %}</h2>
{% if paired_components %}
<table class="table table-hover attr-table">
{% for component, own_position, other_position in paired_components %}
<tr>
<th scope="row">{% trans "Position" %} {{ own_position }}</th>
<td>{{ component|linkify }} &middot; {% trans "position" %} {{ other_position }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="card-body text-muted">
{% trans "No mapping to a port on the other side. Sync the device's components again if this looks wrong." %}
</div>
{% endif %}
</div>
{% endif %}
</div>
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Connection" %}</h2>
{% if cable %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Cable" %}</th>
<td>{{ cable|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge cable.get_status_display bg_color=cable.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Connected to" %}</th>
<td>
{% if cable.termination_a_id == object.pk %}
{{ cable.termination_b.device|linkify }} &middot; {{ cable.termination_b|linkify }}
{% else %}
{{ cable.termination_a.device|linkify }} &middot; {{ cable.termination_a|linkify }}
{% endif %}
</td>
</tr>
</table>
{% else %}
<div class="card-body text-muted">{% trans "Not connected." %}</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
@@ -1,6 +1,16 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block extra_controls %}
{{ block.super }}
{% if object.device_type and perms.netbox_rack_concept.add_conceptcomponent %}
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_sync_components' pk=object.pk %}" class="btn btn-outline-secondary">
<i class="mdi mdi-sync"></i> {% trans "Sync components" %}
</a>
{% endif %}
{% endblock %}
{% block content %}
<div class="row mb-3">
@@ -99,4 +109,21 @@
</div>
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "Components" %}
{% if perms.netbox_rack_concept.add_conceptcomponent %}
<a href="{% url 'plugins:netbox_rack_concept:conceptcomponent_add' %}?device={{ object.pk }}"
class="btn btn-sm btn-primary float-end">
<i class="mdi mdi-plus-thick"></i> {% trans "Add component" %}
</a>
{% endif %}
</h2>
{% render_table component_table %}
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,34 @@
{% extends 'base/layout.html' %}
{% load i18n %}
{% block title %}{% blocktrans with device=object %}Sync components for {{ device }}{% endblocktrans %}{% 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 "Sync components from device type" %}</h2>
<div class="card-body">
<p>
{% blocktrans with device=object device_type=object.device_type %}
Creates any interfaces, ports and power connections that {{ device_type }} defines but
{{ device }} doesn't have yet, matched by name. Existing components - and any cables already
attached to them - are left exactly as they are, so this is safe to run again later, e.g. after
adding a component by hand or changing the device type.
{% endblocktrans %}
</p>
<p class="text-muted">
{% trans "Front and rear ports are linked the same way they are on the device type, so a patch panel's pass-through mapping carries over automatically." %}
</p>
</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 "Sync components" %}</button>
</div>
</div>
</div>
</div>
</form>
{% endblock %}
@@ -12,13 +12,25 @@
class="concept-elevation-svg" role="img"
aria-label="{% blocktrans with face=elevation.face_label name=elevation.concept.name %}{{ face }} elevation of {{ name }}{% endblocktrans %}">
{# Unit rows and legend #}
{# Unit rows and legend. An empty unit becomes a click target for adding a device
here, exactly like a real rack's elevation. #}
{% for unit in elevation.units %}
<g class="unit">
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
class="unit-slot"
data-unit="{{ unit.number }}"></rect>
{% if not unit.occupied and can_add_device %}
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ elevation.concept.pk }}&amp;position={{ unit.number }}&amp;face={{ elevation.face }}&amp;return_url={{ elevation.concept.get_absolute_url }}">
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
class="unit-slot free"
data-unit="{{ unit.number }}"></rect>
<text x="{{ elevation.rack_right|add:'-6' }}" y="{{ unit.y }}" dy="15"
class="add-device-hint" text-anchor="end">+ {% trans "Add device" %}</text>
</a>
{% else %}
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
class="unit-slot"
data-unit="{{ unit.number }}"></rect>
{% endif %}
<text x="{{ elevation.rack_x }}" y="{{ unit.y }}" dx="-4" dy="15"
class="unit-label" text-anchor="end">{{ unit.number }}</text>
</g>
@@ -150,19 +150,11 @@
data-starting-unit="{{ object.starting_unit }}"
data-u-height="{{ object.u_height }}"
data-desc-units="{{ object.desc_units|yesno:'true,false' }}">
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=front_elevation %}
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=rear_elevation %}
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=front_elevation can_add_device=perms.netbox_rack_concept.add_conceptdevice %}
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=rear_elevation can_add_device=perms.netbox_rack_concept.add_conceptdevice %}
</div>
<div id="concept-elevation-status" class="small text-muted mt-2"></div>
</div>
{% if perms.netbox_rack_concept.add_conceptdevice %}
<div class="card-footer text-end">
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ object.pk }}&return_url={{ object.get_absolute_url }}"
class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick"></i> {% trans "Add planned device" %}
</a>
</div>
{% endif %}
</div>
<div class="card">