Просмотр исходного кода

refactor(virtualization): Port to declarative layout

Add declarative layout panels for Cluster, Cluster Group, Cluster Type,
Virtual Disk, and VM Interface, including addressing, VLAN assignment,
and FHRP group handling.

Expand the declarative layout primitives:
- add GFK attribute rendering support
- add panel for rendering context-provided tables
- update templates to support new panels/attrs

Closes #20923
Martin Hauser 14 часов назад
Родитель
Сommit
cc47afc401

+ 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)