Browse Source

Merge pull request #21496 from netbox-community/20923-convert-virtualization-views-to-new-ui-layout

Closes #20923: Migrate Virtualization object views to declarative layouts
bctiemann 18 hours ago
parent
commit
ae6f1f9ae3

+ 0 - 0
netbox/ipam/ui/__init__.py


+ 37 - 0
netbox/ipam/ui/panels.py

@@ -0,0 +1,37 @@
+from django.contrib.contenttypes.models import ContentType
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+
+from netbox.ui import actions, panels
+
+
+class FHRPGroupAssignmentsPanel(panels.ObjectPanel):
+    """
+    A panel which lists all FHRP group assignments for a given object.
+    """
+
+    template_name = 'ipam/panels/fhrp_groups.html'
+    title = _('FHRP Groups')
+    actions = [
+        actions.AddObject(
+            'ipam.FHRPGroup',
+            url_params={
+                'return_url': lambda ctx: reverse(
+                    'ipam:fhrpgroupassignment_add',
+                    query={
+                        'interface_type': ContentType.objects.get_for_model(ctx['object']).pk,
+                        'interface_id': ctx['object'].pk,
+                    },
+                ),
+            },
+            label=_('Create Group'),
+        ),
+        actions.AddObject(
+            'ipam.FHRPGroupAssignment',
+            url_params={
+                'interface_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
+                'interface_id': lambda ctx: ctx['object'].pk,
+            },
+            label=_('Assign Group'),
+        ),
+    ]

+ 27 - 0
netbox/netbox/ui/attrs.py

@@ -10,6 +10,7 @@ __all__ = (
     'BooleanAttr',
     'ColorAttr',
     'ChoiceAttr',
+    'GenericForeignKeyAttr',
     'GPSCoordinatesAttr',
     'ImageAttr',
     'NestedObjectAttr',
@@ -279,6 +280,32 @@ class NestedObjectAttr(ObjectAttribute):
         }
 
 
+class GenericForeignKeyAttr(ObjectAttribute):
+    """
+    An attribute representing a related generic relation object.
+
+    This attribute is similar to `RelatedObjectAttr` but uses the
+    ContentType of the related object to be displayed alongside the value.
+
+    Parameters:
+         linkify (bool): If True, the rendered value will be hyperlinked
+             to the related object's detail view
+    """
+    template_name = 'ui/attrs/generic_object.html'
+
+    def __init__(self, *args, linkify=None, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.linkify = linkify
+
+    def get_context(self, obj, context):
+        value = self.get_value(obj)
+        content_type = value._meta.verbose_name
+        return {
+            'content_type': content_type,
+            'linkify': self.linkify,
+        }
+
+
 class AddressAttr(ObjectAttribute):
     """
     A physical or mailing address.

+ 40 - 0
netbox/netbox/ui/panels.py

@@ -12,6 +12,7 @@ from utilities.views import get_viewname
 
 __all__ = (
     'CommentsPanel',
+    'ContextTablePanel',
     'JSONPanel',
     'NestedGroupObjectPanel',
     'ObjectAttributesPanel',
@@ -339,3 +340,42 @@ class PluginContentPanel(Panel):
     def render(self, context):
         obj = context.get('object')
         return _get_registered_content(obj, self.method, context)
+
+
+class ContextTablePanel(ObjectPanel):
+    """
+    A panel which renders a django-tables2/NetBoxTable instance provided
+    via the view's extra context.
+
+    This is useful when you already have a fully constructed table
+    (custom queryset, special columns, no list view) and just want to
+    render it inside a declarative layout panel.
+
+    Parameters:
+        table (str | callable): Either the context key holding the table
+            (e.g. "vlan_table") or a callable which accepts the template
+            context and returns a table instance.
+    """
+    template_name = 'ui/panels/context_table.html'
+
+    def __init__(self, table, **kwargs):
+        super().__init__(**kwargs)
+        self.table = table
+
+    def _resolve_table(self, context):
+        if callable(self.table):
+            return self.table(context)
+        return context.get(self.table)
+
+    def get_context(self, context):
+        table = self._resolve_table(context)
+        return {
+            **super().get_context(context),
+            'table': table,
+        }
+
+    def render(self, context):
+        table = self._resolve_table(context)
+        if table is None:
+            return ''
+        return super().render(context)

+ 47 - 0
netbox/templates/ipam/panels/fhrp_groups.html

@@ -0,0 +1,47 @@
+{% extends "ui/panels/_base.html" %}
+{% load perms %}
+{% load i18n %}
+
+{% block panel_content %}
+  <table class="table table-hover attr-table">
+    <thead>
+      <tr class="border-bottom">
+        <th>{% trans "Group" %}</th>
+        <th>{% trans "Protocol" %}</th>
+        <th>{% trans "Virtual IPs" %}</th>
+        <th>{% trans "Priority" %}</th>
+        <th></th>
+      </tr>
+    </thead>
+    <tbody>
+      {% for assignment in object.fhrp_group_assignments.all %}
+        <tr>
+          <td>{{ assignment.group|linkify:"group_id" }}</td>
+          <td>{{ assignment.group.get_protocol_display }}</td>
+          <td>
+            {% for ipaddress in assignment.group.ip_addresses.all %}
+              {{ ipaddress|linkify }}{% if not forloop.last %}<br />{% endif %}
+            {% endfor %}
+          </td>
+          <td>{{ assignment.priority }}</td>
+          <td class="text-end d-print-none">
+            {% if request.user|can_change:assignment %}
+              <a href="{% url 'ipam:fhrpgroupassignment_edit' pk=assignment.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-warning" title="{% trans "Edit" %}">
+                <i class="mdi mdi-pencil" aria-hidden="true"></i>
+              </a>
+            {% endif %}
+            {% if request.user|can_delete:assignment %}
+              <a href="{% url 'ipam:fhrpgroupassignment_delete' pk=assignment.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-danger" title="{% trans "Delete" %}">
+                <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
+              </a>
+            {% endif %}
+          </td>
+        </tr>
+      {% empty %}
+        <tr>
+          <td colspan="5" class="text-muted">{% trans "None" %}</td>
+        </tr>
+      {% endfor %}
+    </tbody>
+  </table>
+{% endblock panel_content %}

+ 3 - 0
netbox/templates/ui/attrs/generic_object.html

@@ -0,0 +1,3 @@
+<span{% if name %} id="attr_{{ name }}"{% endif %}>
+  {% if linkify %}{{ value|linkify }}{% else %}{{ value }}{% endif %}{% if content_type %} ({{ content_type }}){% endif %}
+</span>

+ 6 - 0
netbox/templates/ui/panels/context_table.html

@@ -0,0 +1,6 @@
+{% extends "ui/panels/_base.html" %}
+{% load render_table from django_tables2 %}
+
+{% block panel_content %}
+  {% render_table table 'inc/table.html' %}
+{% endblock panel_content %}

+ 0 - 94
netbox/templates/virtualization/cluster.html

@@ -1,95 +1 @@
 {% extends 'virtualization/cluster/base.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
-
-{% block content %}
-<div class="row">
-  <div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Cluster" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Name" %}</th>
-          <td>{{ object.name }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Type" %}</th>
-          <td>{{ object.type|linkify }}</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 "Description" %}</th>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Group" %}</th>
-          <td>{{ object.group|linkify|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Tenant" %}</th>
-          <td>
-            {% if object.tenant.group %}
-              {{ object.tenant.group|linkify }} /
-            {% endif %}
-            {{ object.tenant|linkify|placeholder }}
-            </td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Scope" %}</th>
-          {% if object.scope %}
-            <td>{{ object.scope|linkify }} ({% trans object.scope_type.name %})</td>
-          {% else %}
-            <td>{{ ''|placeholder }}</td>
-          {% endif %}
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/comments.html' %}
-    {% plugin_left_page object %}
-  </div>
-  <div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Allocated Resources" %}</h2>
-      <table class="table table-hover attr-table">
-          <tr>
-              <th scope="row"><i class="mdi mdi-gauge"></i> {% trans "Virtual CPUs" %}</th>
-              <td>{{ vcpus_sum|placeholder }}</td>
-          </tr>
-          <tr>
-              <th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
-              <td>
-                  {% if memory_sum %}
-                      <span title={{ memory_sum }}>{{ memory_sum|humanize_ram_megabytes }}</span>
-                  {% else %}
-                      {{ ''|placeholder }}
-                  {% endif %}
-              </td>
-          </tr>
-          <tr>
-              <th scope="row"><i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}</th>
-              <td>
-                  {% if disk_sum %}
-                      {{ disk_sum|humanize_disk_megabytes }}
-                  {% else %}
-                      {{ ''|placeholder }}
-                  {% endif %}
-              </td>
-          </tr>
-      </table>
-  </div>
-    {% include 'inc/panels/related_objects.html' %}
-    {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/tags.html' %}
-    {% plugin_right_page object %}
-  </div>
-</div>
-<div class="row">
-  <div class="col col-md-12">
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 0 - 36
netbox/templates/virtualization/clustergroup.html

@@ -1,7 +1,4 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 
 {% block extra_controls %}
@@ -11,36 +8,3 @@
     </a>
   {% endif %}
 {% endblock extra_controls %}
-
-{% block content %}
-<div class="row mb-3">
-	<div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Cluster Group" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Name" %}</th>
-          <td>{{ object.name }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Description" %}</th>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/tags.html' %}
-    {% plugin_left_page object %}
-	</div>
-	<div class="col col-12 col-md-6">
-    {% include 'inc/panels/related_objects.html' %}
-    {% include 'inc/panels/comments.html' %}
-    {% include 'inc/panels/custom_fields.html' %}
-    {% plugin_right_page object %}
-  </div>
-</div>
-<div class="row">
-	<div class="col col-md-12">
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 0 - 42
netbox/templates/virtualization/clustertype.html

@@ -1,7 +1,4 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 
 {% block extra_controls %}
@@ -11,42 +8,3 @@
     </a>
   {% endif %}
 {% endblock extra_controls %}
-
-{% block content %}
-<div class="row mb-3">
-	<div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Cluster Type" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Name" %}</th>
-          <td>{{ object.name }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Description" %}</th>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Clusters" %}</th>
-          <td>
-            <a href="{% url 'virtualization:cluster_list' %}?type_id={{ object.pk }}">{{ object.clusters.count }}</a>
-          </td>
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/tags.html' %}
-    {% plugin_left_page object %}
-	</div>
-	<div class="col col-12 col-md-6">
-    {% include 'inc/panels/related_objects.html' %}
-    {% include 'inc/panels/comments.html' %}
-    {% include 'inc/panels/custom_fields.html' %}
-    {% plugin_right_page object %}
-  </div>
-</div>
-<div class="row">
-	<div class="col col-md-12">
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 34 - 0
netbox/templates/virtualization/panels/cluster_resources.html

@@ -0,0 +1,34 @@
+{% load helpers %}
+{% load i18n %}
+
+<div class="card">
+  <h2 class="card-header">{% trans "Allocated Resources" %}</h2>
+  <table class="table table-hover attr-table">
+    <tr>
+      <th scope="row"><i class="mdi mdi-gauge"></i> {% trans "Virtual CPUs" %}</th>
+      <td>{{ vcpus_sum|placeholder }}</td>
+    </tr>
+    <tr>
+      <th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
+      <td>
+        {% if memory_sum %}
+          <span title={{ memory_sum }}>{{ memory_sum|humanize_ram_megabytes }}</span>
+        {% else %}
+          {{ ''|placeholder }}
+        {% endif %}
+      </td>
+    </tr>
+    <tr>
+      <th scope="row">
+        <i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}
+      </th>
+      <td>
+        {% if disk_sum %}
+          {{ disk_sum|humanize_disk_megabytes }}
+        {% else %}
+          {{ ''|placeholder }}
+        {% endif %}
+      </td>
+    </tr>
+  </table>
+</div>

+ 0 - 45
netbox/templates/virtualization/virtualdisk.html

@@ -10,48 +10,3 @@
      <a href="{% url 'virtualization:virtualmachine_disks' pk=object.virtual_machine.pk %}">{{ object.virtual_machine }}</a>
   </li>
 {% endblock %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Virtual Disk" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Virtual Machine" %}</th>
-            <td>{{ object.virtual_machine|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Name" %}</th>
-            <td>{{ object.name }}</td>
-          </tr>
-          <tr>
-            <th scope="row"><i class="mdi mdi-harddisk"></i> {% trans "Size" %}</th>
-            <td>
-              {% if object.size %}
-                {{ object.size|humanize_disk_megabytes }}
-              {% else %}
-                {{ ''|placeholder }}
-              {% endif %}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</td>
-          </tr>
-        </table>
-      </div>
-      {% include 'inc/panels/tags.html' %}
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      {% include 'inc/panels/custom_fields.html' %}
-      {% plugin_right_page object %}
-    </div>
-  </div>
-  <div class="row">
-    <div class="col col-md-12">
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 2 - 0
netbox/templates/virtualization/virtualdisk/attrs/size.html

@@ -0,0 +1,2 @@
+{% load helpers %}
+{{ value|humanize_disk_megabytes }}

+ 0 - 152
netbox/templates/virtualization/vminterface.html

@@ -1,8 +1,4 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
 
 {% block breadcrumbs %}
   {{ block.super }}
@@ -10,151 +6,3 @@
     <a href="{% url 'virtualization:virtualmachine_interfaces' pk=object.virtual_machine.pk %}">{{ object.virtual_machine }}</a>
   </li>
 {% endblock %}
-
-{% block content %}
-<div class="row mb-3">
-	<div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Interface" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Virtual Machine" %}</th>
-          <td>{{ object.virtual_machine|linkify }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Name" %}</th>
-          <td>{{ object.name }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Enabled" %}</th>
-          <td>
-            {% if object.enabled %}
-              <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
-            {% else %}
-              <span class="text-danger"><i class="mdi mdi-close"></i></span>
-            {% endif %}
-          </td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Parent" %}</th>
-          <td>{{ object.parent|linkify|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Bridge" %}</th>
-          <td>{{ object.bridge|linkify|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Description" %}</th>
-          <td>{{ object.description|placeholder }} </td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "MTU" %}</th>
-          <td>{{ object.mtu|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "802.1Q Mode" %}</th>
-          <td>{{ object.get_mode_display|placeholder }}</td>
-        </tr>
-        {% if object.mode == 'q-in-q' %}
-          <tr>
-              <th scope="row">{% trans "Q-in-Q SVLAN" %}</th>
-              <td>{{ object.qinq_svlan|linkify|placeholder }}</td>
-          </tr>
-        {% endif %}
-        <tr>
-          <th scope="row">{% trans "Tunnel" %}</th>
-          <td>{{ object.tunnel_termination.tunnel|linkify|placeholder }}</td>
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/tags.html' %}
-    {% plugin_left_page object %}
-  </div>
-  <div class="col col-12 col-md-6">
-    {% include 'inc/panels/custom_fields.html' %}
-    <div class="card">
-      <h2 class="card-header">{% trans "Addressing" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "MAC Address" %}</th>
-          <td>
-            {% if object.primary_mac_address %}
-              <span class="font-monospace">{{ object.primary_mac_address|linkify }}</span>
-              <span class="badge text-bg-primary">{% trans "Primary" %}</span>
-            {% else %}
-              {{ ''|placeholder }}
-            {% endif %}
-          </td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "VRF" %}</th>
-          <td>{{ object.vrf|linkify|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "VLAN Translation" %}</th>
-          <td>{{ object.vlan_translation_policy|linkify|placeholder }}</td>
-        </tr>
-      </table>
-    </div>
-    {% include 'ipam/inc/panels/fhrp_groups.html' %}
-    {% plugin_right_page object %}
-  </div>
-</div>
-<div class="row mb-3">
-    <div class="col col-md-12">
-        <div class="card">
-            <h2 class="card-header">
-              {% trans "IP Addresses" %}
-              {% if perms.ipam.add_ipaddress %}
-                <div class="card-actions">
-                  <a href="{% url 'ipam:ipaddress_add' %}?virtual_machine={{ object.virtual_machine.pk }}&vminterface={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
-                    <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add IP Address" %}
-                  </a>
-                </div>
-              {% endif %}
-            </h2>
-            {% htmx_table 'ipam:ipaddress_list' vminterface_id=object.pk %}
-        </div>
-    </div>
-</div>
-<div class="row mb-3">
-    <div class="col col-md-12">
-        <div class="card">
-            <h2 class="card-header">
-                {% trans "MAC Addresses" %}
-                {% if perms.ipam.add_macaddress %}
-                    <div class="card-actions">
-                        <a href="{% url 'dcim:macaddress_add' %}?virtual_machine={{ object.device.pk }}&vminterface={{ object.pk }}&return_url={{ object.get_absolute_url }}"
-                           class="btn btn-ghost-primary btn-sm">
-                            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add MAC Address" %}
-                        </a>
-                    </div>
-                {% endif %}
-            </h2>
-            {% htmx_table 'dcim:macaddress_list' vminterface_id=object.pk %}
-        </div>
-    </div>
-</div>
-<div class="row mb-3">
-    <div class="col col-md-12">
-        {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
-    </div>
-</div>
-{% if object.vlan_translation_policy %}
-    <div class="row mb-3">
-        <div class="col col-md-12">
-            {% include 'inc/panel_table.html' with table=vlan_translation_table heading="VLAN Translation" %}
-        </div>
-    </div>
-{% endif %}
-<div class="row mb-3">
-    <div class="col col-md-12">
-        {% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
-    </div>
-</div>
-<div class="row">
-    <div class="col col-md-12">
-        {% plugin_full_width_page object %}
-    </div>
-</div>
-{% endblock %}

+ 42 - 0
netbox/virtualization/ui/panels.py

@@ -3,6 +3,16 @@ from django.utils.translation import gettext_lazy as _
 from netbox.ui import attrs, panels
 
 
+class ClusterPanel(panels.ObjectAttributesPanel):
+    name = attrs.TextAttr('name')
+    type = attrs.RelatedObjectAttr('type', linkify=True)
+    status = attrs.ChoiceAttr('status')
+    description = attrs.TextAttr('description')
+    group = attrs.RelatedObjectAttr('group', linkify=True)
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    scope = attrs.GenericForeignKeyAttr('scope', linkify=True)
+
+
 class VirtualMachinePanel(panels.ObjectAttributesPanel):
     name = attrs.TextAttr('name')
     status = attrs.ChoiceAttr('status')
@@ -32,3 +42,35 @@ class VirtualMachineClusterPanel(panels.ObjectAttributesPanel):
     cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
     cluster_type = attrs.RelatedObjectAttr('cluster.type', linkify=True)
     device = attrs.RelatedObjectAttr('device', linkify=True)
+
+
+class VirtualDiskPanel(panels.ObjectAttributesPanel):
+    virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True, label=_('Virtual Machine'))
+    name = attrs.TextAttr('name')
+    size = attrs.TemplatedAttr('size', template_name='virtualization/virtualdisk/attrs/size.html')
+    description = attrs.TextAttr('description')
+
+
+class VMInterfacePanel(panels.ObjectAttributesPanel):
+    virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True, label=_('Virtual Machine'))
+    name = attrs.TextAttr('name')
+    enabled = attrs.BooleanAttr('enabled')
+    parent = attrs.RelatedObjectAttr('parent_interface', linkify=True)
+    bridge = attrs.RelatedObjectAttr('bridge', linkify=True)
+    description = attrs.TextAttr('description')
+    mtu = attrs.TextAttr('mtu', label=_('MTU'))
+    mode = attrs.ChoiceAttr('mode', label=_('802.1Q Mode'))
+    qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
+    tunnel_termination = attrs.RelatedObjectAttr('tunnel_termination.tunnel', linkify=True, label=_('Tunnel'))
+
+
+class VMInterfaceAddressingPanel(panels.ObjectAttributesPanel):
+    title = _('Addressing')
+
+    primary_mac_address = attrs.TextAttr(
+        'primary_mac_address', label=_('MAC Address'), style='font-monospace', copy_button=True
+    )
+    vrf = attrs.RelatedObjectAttr('vrf', linkify=True, label=_('VRF'))
+    vlan_translation_policy = attrs.RelatedObjectAttr(
+        'vlan_translation_policy', linkify=True, label=_('VLAN Translation')
+    )

+ 91 - 1
netbox/virtualization/views.py

@@ -14,6 +14,7 @@ from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from ipam.models import IPAddress, VLANGroup
 from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
+from ipam.ui.panels import FHRPGroupAssignmentsPanel
 from netbox.object_actions import (
     AddObject,
     BulkDelete,
@@ -25,7 +26,14 @@ from netbox.object_actions import (
     EditObject,
 )
 from netbox.ui import actions, layout
-from netbox.ui.panels import CommentsPanel, ObjectsTablePanel, TemplatePanel
+from netbox.ui.panels import (
+    CommentsPanel,
+    ContextTablePanel,
+    ObjectsTablePanel,
+    OrganizationalObjectPanel,
+    RelatedObjectsPanel,
+    TemplatePanel,
+)
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.query_functions import CollateAsChar
@@ -54,6 +62,17 @@ class ClusterTypeListView(generic.ObjectListView):
 @register_model_view(ClusterType)
 class ClusterTypeView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ClusterType.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            OrganizationalObjectPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+            CommentsPanel(),
+        ],
+    )
 
     def get_extra_context(self, request, instance):
         return {
@@ -121,6 +140,17 @@ class ClusterGroupListView(generic.ObjectListView):
 @register_model_view(ClusterGroup)
 class ClusterGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ClusterGroup.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            OrganizationalObjectPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+            CommentsPanel(),
+        ],
+    )
 
     def get_extra_context(self, request, instance):
         return {
@@ -202,6 +232,18 @@ class ClusterListView(generic.ObjectListView):
 @register_model_view(Cluster)
 class ClusterView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Cluster.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ClusterPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            TemplatePanel('virtualization/panels/cluster_resources.html'),
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+    )
 
     def get_extra_context(self, request, instance):
         return {
@@ -507,6 +549,7 @@ class VirtualMachineBulkDeleteView(generic.BulkDeleteView):
 # VM interfaces
 #
 
+
 @register_model_view(VMInterface, 'list', path='', detail=False)
 class VMInterfaceListView(generic.ObjectListView):
     queryset = VMInterface.objects.all()
@@ -518,6 +561,44 @@ class VMInterfaceListView(generic.ObjectListView):
 @register_model_view(VMInterface)
 class VMInterfaceView(generic.ObjectView):
     queryset = VMInterface.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.VMInterfacePanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            CustomFieldsPanel(),
+            panels.VMInterfaceAddressingPanel(),
+            FHRPGroupAssignmentsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='ipam.IPaddress',
+                filters={'vminterface_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject(
+                        'ipam.IPaddress',
+                        url_params={
+                            'virtual_machine': lambda ctx: ctx['object'].virtual_machine.pk,
+                            'vminterface': lambda ctx: ctx['object'].pk,
+                        },
+                    ),
+                ],
+            ),
+            ObjectsTablePanel(
+                model='dcim.MACAddress',
+                filters={'vminterface_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject(
+                        'dcim.MACAddress', url_params={'vminterface': lambda ctx: ctx['object'].pk}
+                    ),
+                ],
+            ),
+            ContextTablePanel('vlan_table', title=_('Assigned VLANs')),
+            ContextTablePanel('vlan_translation_table', title=_('VLAN Translation')),
+            ContextTablePanel('child_interfaces_table', title=_('Child Interfaces')),
+        ],
+    )
 
     def get_extra_context(self, request, instance):
 
@@ -623,6 +704,15 @@ class VirtualDiskListView(generic.ObjectListView):
 @register_model_view(VirtualDisk)
 class VirtualDiskView(generic.ObjectView):
     queryset = VirtualDisk.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.VirtualDiskPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            CustomFieldsPanel(),
+        ],
+    )
 
 
 @register_model_view(VirtualDisk, 'add', detail=False)