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:
@@ -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 }}"> </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 }}&termination_a_device={{ object.device_id }}&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 }} · {% 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 }} · {{ cable.termination_b|linkify }}
|
||||
{% else %}
|
||||
{{ cable.termination_a.device|linkify }} · {{ 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 }}&position={{ unit.number }}&face={{ elevation.face }}&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">
|
||||
|
||||
Reference in New Issue
Block a user