Repository: netbox-community/netbox

Issue:
Add a view showing utilization statistics for each RIR
This would be a convenience view showing active, reserved, deprecated, and available IPv4 and IPv6 space per RIR, primarily for capacity planning purposes.


Discussion:

Resolving patch:
diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py
--- a/netbox/ipam/models.py
+++ b/netbox/ipam/models.py
@@ -22,23 +22,33 @@
     (6, 'IPv6'),
 )
 
+PREFIX_STATUS_CONTAINER = 0
+PREFIX_STATUS_ACTIVE = 1
+PREFIX_STATUS_RESERVED = 2
+PREFIX_STATUS_DEPRECATED = 3
 PREFIX_STATUS_CHOICES = (
-    (0, 'Container'),
-    (1, 'Active'),
-    (2, 'Reserved'),
-    (3, 'Deprecated')
+    (PREFIX_STATUS_CONTAINER, 'Container'),
+    (PREFIX_STATUS_ACTIVE, 'Active'),
+    (PREFIX_STATUS_RESERVED, 'Reserved'),
+    (PREFIX_STATUS_DEPRECATED, 'Deprecated')
 )
 
+IPADDRESS_STATUS_ACTIVE = 1
+IPADDRESS_STATUS_RESERVED = 2
+IPADDRESS_STATUS_DHCP = 5
 IPADDRESS_STATUS_CHOICES = (
-    (1, 'Active'),
-    (2, 'Reserved'),
-    (5, 'DHCP')
+    (IPADDRESS_STATUS_ACTIVE, 'Active'),
+    (IPADDRESS_STATUS_RESERVED, 'Reserved'),
+    (IPADDRESS_STATUS_DHCP, 'DHCP')
 )
 
+VLAN_STATUS_ACTIVE = 1
+VLAN_STATUS_RESERVED = 2
+VLAN_STATUS_DEPRECATED = 3
 VLAN_STATUS_CHOICES = (
-    (1, 'Active'),
-    (2, 'Reserved'),
-    (3, 'Deprecated')
+    (VLAN_STATUS_ACTIVE, 'Active'),
+    (VLAN_STATUS_RESERVED, 'Reserved'),
+    (VLAN_STATUS_DEPRECATED, 'Deprecated')
 )
 
 STATUS_CHOICE_CLASSES = {
diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py
--- a/netbox/ipam/tables.py
+++ b/netbox/ipam/tables.py
@@ -6,6 +6,25 @@
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
 
 
+RIR_UTILIZATION = """
+<div class="progress">
+    {% if record.stats.total %}
+        <div class="progress-bar" role="progressbar" style="width: {{ record.stats.percentages.active }}%;">
+            <span class="sr-only">{{ record.stats.percentages.active }}%</span>
+        </div>
+        <div class="progress-bar progress-bar-info" role="progressbar" style="width: {{ record.stats.percentages.reserved }}%;">
+            <span class="sr-only">{{ record.stats.percentages.reserved }}%</span>
+        </div>
+        <div class="progress-bar progress-bar-danger" role="progressbar" style="width: {{ record.stats.percentages.deprecated }}%;">
+            <span class="sr-only">{{ record.stats.percentages.deprecated }}%</span>
+        </div>
+        <div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ record.stats.percentages.available }}%;">
+            <span class="sr-only">{{ record.stats.percentages.available }}%</span>
+        </div>
+    {% endif %}
+</div>
+"""
+
 RIR_ACTIONS = """
 {% if perms.ipam.change_rir %}
     <a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -108,12 +127,22 @@ class RIRTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn(verbose_name='Name')
     aggregate_count = tables.Column(verbose_name='Aggregates')
-    slug = tables.Column(verbose_name='Slug')
+    stats_total = tables.Column(accessor='stats.total', verbose_name='Total',
+                                footer=lambda table: sum(r.stats['total'] for r in table.data))
+    stats_active = tables.Column(accessor='stats.active', verbose_name='Active',
+                                 footer=lambda table: sum(r.stats['active'] for r in table.data))
+    stats_reserved = tables.Column(accessor='stats.reserved', verbose_name='Reserved',
+                                   footer=lambda table: sum(r.stats['reserved'] for r in table.data))
+    stats_deprecated = tables.Column(accessor='stats.deprecated', verbose_name='Deprecated',
+                                     footer=lambda table: sum(r.stats['deprecated'] for r in table.data))
+    stats_available = tables.Column(accessor='stats.available', verbose_name='Available',
+                                    footer=lambda table: sum(r.stats['available'] for r in table.data))
+    utilization = tables.TemplateColumn(template_code=RIR_UTILIZATION, verbose_name='Utilization')
     actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
 
     class Meta(BaseTable.Meta):
         model = RIR
-        fields = ('pk', 'name', 'aggregate_count', 'slug', 'actions')
+        fields = ('pk', 'name', 'aggregate_count', 'stats_total', 'stats_active', 'stats_reserved', 'stats_deprecated', 'stats_available', 'utilization', 'actions')
 
 
 #
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -1,5 +1,6 @@
-import netaddr
+from collections import OrderedDict
 from django_tables2 import RequestConfig
+import netaddr
 
 from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
@@ -16,7 +17,7 @@
 )
 
 from . import filters, forms, tables
-from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
+from .models import Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role, VLAN, VLANGroup, VRF
 
 
 def add_available_prefixes(parent, prefix_list):
@@ -157,6 +158,82 @@ class RIRListView(ObjectListView):
     edit_permissions = ['ipam.change_rir', 'ipam.delete_rir']
     template_name = 'ipam/rir_list.html'
 
+    def alter_queryset(self, request):
+
+        if request.GET.get('family') == '6':
+            family = 6
+            denominator = 2 ** 64  # Count /64s for IPv6 rather than individual IPs
+        else:
+            family = 4
+            denominator = 1
+
+        rirs = []
+        for rir in self.queryset:
+
+            stats = {
+                'total': 0,
+                'active': 0,
+                'reserved': 0,
+                'deprecated': 0,
+                'available': 0,
+            }
+            aggregate_list = Aggregate.objects.filter(family=family, rir=rir)
+            for aggregate in aggregate_list:
+
+                queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix))
+
+                # Find all consumed space for each prefix status (we ignore containers for this purpose).
+                active_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_ACTIVE)])
+                reserved_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_RESERVED)])
+                deprecated_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_DEPRECATED)])
+
+                # Find all available prefixes by subtracting each of the existing prefix sets from the aggregate prefix.
+                available_prefixes = (
+                    netaddr.IPSet([aggregate.prefix]) -
+                    netaddr.IPSet(active_prefixes) -
+                    netaddr.IPSet(reserved_prefixes) -
+                    netaddr.IPSet(deprecated_prefixes)
+                )
+
+                # Add the size of each metric to the RIR total.
+                stats['total'] += aggregate.prefix.size / denominator
+                stats['active'] += netaddr.IPSet(active_prefixes).size / denominator
+                stats['reserved'] += netaddr.IPSet(reserved_prefixes).size / denominator
+                stats['deprecated'] += netaddr.IPSet(deprecated_prefixes).size / denominator
+                stats['available'] += available_prefixes.size / denominator
+
+            # Calculate the percentage of total space for each prefix status.
+            total = float(stats['total'])
+            stats['percentages'] = {
+                'active': float('{:.2f}'.format(stats['active'] / total * 100)) if total else 0,
+                'reserved': float('{:.2f}'.format(stats['reserved'] / total * 100)) if total else 0,
+                'deprecated': float('{:.2f}'.format(stats['deprecated'] / total * 100)) if total else 0,
+            }
+            stats['percentages']['available'] = (
+                100 -
+                stats['percentages']['active'] -
+                stats['percentages']['reserved'] -
+                stats['percentages']['deprecated']
+            )
+            rir.stats = stats
+            rirs.append(rir)
+
+        return rirs
+
+    def extra_context(self):
+
+        totals = {
+            'total': sum([rir.stats['total'] for rir in self.queryset]),
+            'active': sum([rir.stats['active'] for rir in self.queryset]),
+            'reserved': sum([rir.stats['reserved'] for rir in self.queryset]),
+            'deprecated': sum([rir.stats['deprecated'] for rir in self.queryset]),
+            'available': sum([rir.stats['available'] for rir in self.queryset]),
+        }
+
+        return {
+            'totals': totals,
+        }
+
 
 class RIREditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'ipam.change_rir'


Test patch: