diff --git a/.github/lock.yml b/.github/lock.yml new file mode 100644 index 000000000..36a41b04e --- /dev/null +++ b/.github/lock.yml @@ -0,0 +1,23 @@ +# Configuration for Lock (https://github.com/apps/lock) + +# Number of days of inactivity before a closed issue or pull request is locked +daysUntilLock: 90 + +# Skip issues and pull requests created before a given timestamp. Timestamp must +# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable +skipCreatedBefore: 2020-01-01 + +# Issues and pull requests with these labels will be ignored. Set to `[]` to disable +exemptLabels: [] + +# Label to add before locking, such as `outdated`. Set to `false` to disable +lockLabel: false + +# Comment to post before locking. Set to `false` to disable +lockComment: false + +# Assign `resolved` as the reason for locking. Set to `false` to disable +setLockReason: true + +# Limit to only `issues` or `pulls` +# only: issues diff --git a/.github/stale.yml b/.github/stale.yml index 7c8d03f12..61201cc4e 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,20 +1,27 @@ +# Configuration for Stale (https://github.com/apps/stale) + # Number of days of inactivity before an issue becomes stale daysUntilStale: 14 + # Number of days of inactivity before a stale issue is closed daysUntilClose: 7 + # Issues with these labels will never be considered stale exemptLabels: - "status: accepted" - "status: gathering feedback" - "status: blocked" + # Label to use when marking an issue as stale staleLabel: wontfix + # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). + # Comment to post when closing a stale issue. Set to `false` to disable closeComment: > This issue has been automatically closed due to lack of activity. In an diff --git a/docs/additional-features/napalm.md b/docs/additional-features/napalm.md new file mode 100644 index 000000000..c8e8b8b3a --- /dev/null +++ b/docs/additional-features/napalm.md @@ -0,0 +1,65 @@ +# NAPALM + +NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. + +!!! info + To enable the integration, the NAPALM library must be installed. See [installation steps](../../installation/2-netbox/#napalm-automation-optional) for more information. + +``` +GET /api/dcim/devices/1/napalm/?method=get_environment + +{ + "get_environment": { + ... + } +} +``` + +## Authentication + +By default, the [`NAPALM_USERNAME`](../../configuration/optional-settings/#napalm_username) and [`NAPALM_PASSWORD`](../../configuration/optional-settings/#napalm_password) are used for NAPALM authentication. They can be overridden for an individual API call through the `X-NAPALM-Username` and `X-NAPALM-Password` headers. + +``` +$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \ +-H "Authorization: Token f4b378553dacfcfd44c5a0b9ae49b57e29c552b5" \ +-H "Content-Type: application/json" \ +-H "Accept: application/json; indent=4" \ +-H "X-NAPALM-Username: foo" \ +-H "X-NAPALM-Password: bar" +``` + +## Method Support + +The list of supported NAPALM methods depends on the [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html#general-support-matrix) configured for the platform of a device. NetBox only supports [get](https://napalm.readthedocs.io/en/latest/support/index.html#getters-support-matrix) methods. + +## Multiple Methods + +More than one method in an API call can be invoked by adding multiple `method` parameters. For example: + +``` +GET /api/dcim/devices/1/napalm/?method=get_ntp_servers&method=get_ntp_peers + +{ + "get_ntp_servers": { + ... + }, + "get_ntp_peers": { + ... + } +} +``` + +## Optional Arguments + +The behavior of NAPALM drivers can be adjusted according to the [optional arguments](https://napalm.readthedocs.io/en/latest/support/index.html#optional-arguments). NetBox exposes those arguments using headers prefixed with `X-NAPALM-`. + + +For instance, the SSH port is changed to 2222 in this API call: + +``` +$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \ +-H "Authorization: Token f4b378553dacfcfd44c5a0b9ae49b57e29c552b5" \ +-H "Content-Type: application/json" \ +-H "Accept: application/json; indent=4" \ +-H "X-NAPALM-port: 2222" +``` diff --git a/docs/release-notes/version-2.6.md b/docs/release-notes/version-2.6.md index f2c2be6db..958bc0e60 100644 --- a/docs/release-notes/version-2.6.md +++ b/docs/release-notes/version-2.6.md @@ -2,19 +2,28 @@ ## Enhancements +* [#1982](https://github.com/netbox-community/netbox/issues/1982) - Improved NAPALM method documentation in Swagger * [#2050](https://github.com/netbox-community/netbox/issues/2050) - Preview image attachments when hovering the link +* [#2113](https://github.com/netbox-community/netbox/issues/2113) - Allow NAPALM driver settings to be changed with request headers * [#2589](https://github.com/netbox-community/netbox/issues/2589) - Toggle for showing available prefixes/ip addresses +* [#3090](https://github.com/netbox-community/netbox/issues/3090) - Add filter field for device interfaces * [#3187](https://github.com/netbox-community/netbox/issues/3187) - Add rack selection field to rack elevations +* [#3393](https://github.com/netbox-community/netbox/issues/3393) - Paginate the circuits at the provider details view +* [#3440](https://github.com/netbox-community/netbox/issues/3440) - Add total length to cable trace * [#3623](https://github.com/netbox-community/netbox/issues/3623) - Add word expansion during interface creation * [#3851](https://github.com/netbox-community/netbox/issues/3851) - Allow passing initial data to custom script forms ## Bug Fixes * [#3589](https://github.com/netbox-community/netbox/issues/3589) - Fix validation on tagged VLANs of an interface +* [#3849](https://github.com/netbox-community/netbox/issues/3849) - Fix ordering of models when dumping data to JSON * [#3853](https://github.com/netbox-community/netbox/issues/3853) - Fix device role link on config context view * [#3856](https://github.com/netbox-community/netbox/issues/3856) - Allow filtering VM interfaces by multiple MAC addresses * [#3857](https://github.com/netbox-community/netbox/issues/3857) - Fix group custom links rendering * [#3862](https://github.com/netbox-community/netbox/issues/3862) - Allow filtering device components by multiple device names +* [#3864](https://github.com/netbox-community/netbox/issues/3864) - Disallow /0 masks +* [#3872](https://github.com/netbox-community/netbox/issues/3872) - Paginate related IPs of an address +* [#3876](https://github.com/netbox-community/netbox/issues/3876) - Fixed min/max to ASN input field at the site creation page --- diff --git a/mkdocs.yml b/mkdocs.yml index cc44921b6..b493a799b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,6 +35,7 @@ pages: - Custom Scripts: 'additional-features/custom-scripts.md' - Export Templates: 'additional-features/export-templates.md' - Graphs: 'additional-features/graphs.md' + - NAPALM: 'additional-features/napalm.md' - Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Reports: 'additional-features/reports.md' - Tags: 'additional-features/tags.md' diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 655b714d7..5d76e38ee 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin @@ -5,9 +6,11 @@ from django.db import transaction from django.db.models import Count, OuterRef, Subquery from django.shortcuts import get_object_or_404, redirect, render from django.views.generic import View +from django_tables2 import RequestConfig from extras.models import Graph, GRAPH_TYPE_PROVIDER from utilities.forms import ConfirmationForm +from utilities.paginator import EnhancedPaginator from utilities.views import ( BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ) @@ -38,9 +41,18 @@ class ProviderView(PermissionRequiredMixin, View): circuits = Circuit.objects.filter(provider=provider).prefetch_related('type', 'tenant', 'terminations__site') show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists() + circuits_table = tables.CircuitTable(circuits, orderable=False) + circuits_table.columns.hide('provider') + + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(circuits_table) + return render(request, 'circuits/provider.html', { 'provider': provider, - 'circuits': circuits, + 'circuits_table': circuits_table, 'show_graphs': show_graphs, }) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 495709268..db5fe992f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -370,6 +370,10 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): return obj.get_config_context() +class DeviceNAPALMSerializer(serializers.Serializer): + method = serializers.DictField() + + class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() cable = NestedCableSerializer(read_only=True) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 12774e4be..6e5523206 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -358,6 +358,17 @@ class DeviceViewSet(CustomFieldModelViewSet): return Response(serializer.data) + @swagger_auto_schema( + manual_parameters=[ + Parameter( + name='method', + in_='query', + required=True, + type=openapi.TYPE_STRING + ) + ], + responses={'200': serializers.DeviceNAPALMSerializer} + ) @action(detail=True, url_path='napalm') def napalm(self, request, pk): """ @@ -396,13 +407,29 @@ class DeviceViewSet(CustomFieldModelViewSet): napalm_methods = request.GET.getlist('method') response = OrderedDict([(m, None) for m in napalm_methods]) ip_address = str(device.primary_ip.address.ip) + username = settings.NAPALM_USERNAME + password = settings.NAPALM_PASSWORD optional_args = settings.NAPALM_ARGS.copy() if device.platform.napalm_args is not None: optional_args.update(device.platform.napalm_args) + + # Update NAPALM parameters according to the request headers + for header in request.headers: + if header[:9].lower() != 'x-napalm-': + continue + + key = header[9:] + if key.lower() == 'username': + username = request.headers[header] + elif key.lower() == 'password': + password = request.headers[header] + elif key: + optional_args[key.lower()] = request.headers[header] + d = driver( hostname=ip_address, - username=settings.NAPALM_USERNAME, - password=settings.NAPALM_PASSWORD, + username=username, + password=password, timeout=settings.NAPALM_TIMEOUT, optional_args=optional_args ) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index ccaa48636..f325e34d4 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -1,4 +1,8 @@ +# BGP ASN bounds +BGP_ASN_MIN = 1 +BGP_ASN_MAX = 2**32 - 1 + # Rack types RACK_TYPE_2POST = 100 RACK_TYPE_4POST = 200 diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index 9624ce0a3..719b6755a 100644 --- a/netbox/dcim/fields.py +++ b/netbox/dcim/fields.py @@ -3,14 +3,21 @@ from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models from netaddr import AddrFormatError, EUI, mac_unix_expanded +from .constants import * + class ASNField(models.BigIntegerField): description = "32-bit ASN field" default_validators = [ - MinValueValidator(1), - MaxValueValidator(4294967295), + MinValueValidator(BGP_ASN_MIN), + MaxValueValidator(BGP_ASN_MAX), ] + def formfield(self, **kwargs): + defaults = {'min_value': BGP_ASN_MIN, 'max_value': BGP_ASN_MAX} + defaults.update(**kwargs) + return super().formfield(**defaults) + class mac_unix_expanded_uppercase(mac_unix_expanded): word_fmt = '%.2X' diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index dbb9cff15..6086491d0 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -292,8 +292,8 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) ) asn = forms.IntegerField( - min_value=1, - max_value=4294967295, + min_value=BGP_ASN_MIN, + max_value=BGP_ASN_MAX, required=False, label='ASN' ) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 8f95fa19a..69c3c3475 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -2755,6 +2755,187 @@ class VirtualChassis(ChangeLoggedModel): ) +# +# Power +# + +class PowerPanel(ChangeLoggedModel): + """ + A distribution point for electrical power; e.g. a data center RPP. + """ + site = models.ForeignKey( + to='Site', + on_delete=models.PROTECT + ) + rack_group = models.ForeignKey( + to='RackGroup', + on_delete=models.PROTECT, + blank=True, + null=True + ) + name = models.CharField( + max_length=50 + ) + + csv_headers = ['site', 'rack_group_name', 'name'] + + class Meta: + ordering = ['site', 'name'] + unique_together = ['site', 'name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dcim:powerpanel', args=[self.pk]) + + def to_csv(self): + return ( + self.site.name, + self.rack_group.name if self.rack_group else None, + self.name, + ) + + def clean(self): + + # RackGroup must belong to assigned Site + if self.rack_group and self.rack_group.site != self.site: + raise ValidationError("Rack group {} ({}) is in a different site than {}".format( + self.rack_group, self.rack_group.site, self.site + )) + + +class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): + """ + An electrical circuit delivered from a PowerPanel. + """ + power_panel = models.ForeignKey( + to='PowerPanel', + on_delete=models.PROTECT, + related_name='powerfeeds' + ) + rack = models.ForeignKey( + to='Rack', + on_delete=models.PROTECT, + blank=True, + null=True + ) + connected_endpoint = models.OneToOneField( + to='dcim.PowerPort', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + blank=True + ) + name = models.CharField( + max_length=50 + ) + status = models.PositiveSmallIntegerField( + choices=POWERFEED_STATUS_CHOICES, + default=POWERFEED_STATUS_ACTIVE + ) + type = models.PositiveSmallIntegerField( + choices=POWERFEED_TYPE_CHOICES, + default=POWERFEED_TYPE_PRIMARY + ) + supply = models.PositiveSmallIntegerField( + choices=POWERFEED_SUPPLY_CHOICES, + default=POWERFEED_SUPPLY_AC + ) + phase = models.PositiveSmallIntegerField( + choices=POWERFEED_PHASE_CHOICES, + default=POWERFEED_PHASE_SINGLE + ) + voltage = models.PositiveSmallIntegerField( + validators=[MinValueValidator(1)], + default=120 + ) + amperage = models.PositiveSmallIntegerField( + validators=[MinValueValidator(1)], + default=20 + ) + max_utilization = models.PositiveSmallIntegerField( + validators=[MinValueValidator(1), MaxValueValidator(100)], + default=80, + help_text="Maximum permissible draw (percentage)" + ) + available_power = models.PositiveSmallIntegerField( + default=0, + editable=False + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + + tags = TaggableManager(through=TaggedItem) + + csv_headers = [ + 'site', 'panel_name', 'rack_group', 'rack_name', 'name', 'status', 'type', 'supply', 'phase', 'voltage', + 'amperage', 'max_utilization', 'comments', + ] + + class Meta: + ordering = ['power_panel', 'name'] + unique_together = ['power_panel', 'name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dcim:powerfeed', args=[self.pk]) + + def to_csv(self): + return ( + self.power_panel.site.name, + self.power_panel.name, + self.rack.group.name if self.rack and self.rack.group else None, + self.rack.name if self.rack else None, + self.name, + self.get_status_display(), + self.get_type_display(), + self.get_supply_display(), + self.get_phase_display(), + self.voltage, + self.amperage, + self.max_utilization, + self.comments, + ) + + def clean(self): + + # Rack must belong to same Site as PowerPanel + if self.rack and self.rack.site != self.power_panel.site: + raise ValidationError("Rack {} ({}) and power panel {} ({}) are in different sites".format( + self.rack, self.rack.site, self.power_panel, self.power_panel.site + )) + + def save(self, *args, **kwargs): + + # Cache the available_power property on the instance + kva = self.voltage * self.amperage * (self.max_utilization / 100) + if self.phase == POWERFEED_PHASE_3PHASE: + self.available_power = round(kva * 1.732) + else: + self.available_power = round(kva) + + super().save(*args, **kwargs) + + def get_type_class(self): + return STATUS_CLASSES[self.type] + + def get_status_class(self): + return STATUS_CLASSES[self.status] + + # # Cables # @@ -2950,6 +3131,8 @@ class Cable(ChangeLoggedModel): # Store the given length (if any) in meters for use in database ordering if self.length and self.length_unit: self._abs_length = to_meters(self.length, self.length_unit) + else: + self._abs_length = None # Store the parent Device for the A and B terminations (if applicable) to enable filtering if hasattr(self.termination_a, 'device'): @@ -3006,184 +3189,3 @@ class Cable(ChangeLoggedModel): b_endpoint = b_path[-1][2] return a_endpoint, b_endpoint, path_status - - -# -# Power -# - -class PowerPanel(ChangeLoggedModel): - """ - A distribution point for electrical power; e.g. a data center RPP. - """ - site = models.ForeignKey( - to='Site', - on_delete=models.PROTECT - ) - rack_group = models.ForeignKey( - to='RackGroup', - on_delete=models.PROTECT, - blank=True, - null=True - ) - name = models.CharField( - max_length=50 - ) - - csv_headers = ['site', 'rack_group_name', 'name'] - - class Meta: - ordering = ['site', 'name'] - unique_together = ['site', 'name'] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse('dcim:powerpanel', args=[self.pk]) - - def to_csv(self): - return ( - self.site.name, - self.rack_group.name if self.rack_group else None, - self.name, - ) - - def clean(self): - - # RackGroup must belong to assigned Site - if self.rack_group and self.rack_group.site != self.site: - raise ValidationError("Rack group {} ({}) is in a different site than {}".format( - self.rack_group, self.rack_group.site, self.site - )) - - -class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): - """ - An electrical circuit delivered from a PowerPanel. - """ - power_panel = models.ForeignKey( - to='PowerPanel', - on_delete=models.PROTECT, - related_name='powerfeeds' - ) - rack = models.ForeignKey( - to='Rack', - on_delete=models.PROTECT, - blank=True, - null=True - ) - connected_endpoint = models.OneToOneField( - to='dcim.PowerPort', - on_delete=models.SET_NULL, - related_name='+', - blank=True, - null=True - ) - connection_status = models.NullBooleanField( - choices=CONNECTION_STATUS_CHOICES, - blank=True - ) - name = models.CharField( - max_length=50 - ) - status = models.PositiveSmallIntegerField( - choices=POWERFEED_STATUS_CHOICES, - default=POWERFEED_STATUS_ACTIVE - ) - type = models.PositiveSmallIntegerField( - choices=POWERFEED_TYPE_CHOICES, - default=POWERFEED_TYPE_PRIMARY - ) - supply = models.PositiveSmallIntegerField( - choices=POWERFEED_SUPPLY_CHOICES, - default=POWERFEED_SUPPLY_AC - ) - phase = models.PositiveSmallIntegerField( - choices=POWERFEED_PHASE_CHOICES, - default=POWERFEED_PHASE_SINGLE - ) - voltage = models.PositiveSmallIntegerField( - validators=[MinValueValidator(1)], - default=120 - ) - amperage = models.PositiveSmallIntegerField( - validators=[MinValueValidator(1)], - default=20 - ) - max_utilization = models.PositiveSmallIntegerField( - validators=[MinValueValidator(1), MaxValueValidator(100)], - default=80, - help_text="Maximum permissible draw (percentage)" - ) - available_power = models.PositiveSmallIntegerField( - default=0, - editable=False - ) - comments = models.TextField( - blank=True - ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) - - tags = TaggableManager(through=TaggedItem) - - csv_headers = [ - 'site', 'panel_name', 'rack_group', 'rack_name', 'name', 'status', 'type', 'supply', 'phase', 'voltage', - 'amperage', 'max_utilization', 'comments', - ] - - class Meta: - ordering = ['power_panel', 'name'] - unique_together = ['power_panel', 'name'] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse('dcim:powerfeed', args=[self.pk]) - - def to_csv(self): - return ( - self.power_panel.site.name, - self.power_panel.name, - self.rack.group.name if self.rack and self.rack.group else None, - self.rack.name if self.rack else None, - self.name, - self.get_status_display(), - self.get_type_display(), - self.get_supply_display(), - self.get_phase_display(), - self.voltage, - self.amperage, - self.max_utilization, - self.comments, - ) - - def clean(self): - - # Rack must belong to same Site as PowerPanel - if self.rack and self.rack.site != self.power_panel.site: - raise ValidationError("Rack {} ({}) and power panel {} ({}) are in different sites".format( - self.rack, self.rack.site, self.power_panel, self.power_panel.site - )) - - def save(self, *args, **kwargs): - - # Cache the available_power property on the instance - kva = self.voltage * self.amperage * (self.max_utilization / 100) - if self.phase == POWERFEED_PHASE_3PHASE: - self.available_power = round(kva * 1.732) - else: - self.available_power = round(kva) - - super().save(*args, **kwargs) - - def get_type_class(self): - return STATUS_CLASSES[self.type] - - def get_status_class(self): - return STATUS_CLASSES[self.status] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2d98515cf..55a08fdb8 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1754,10 +1754,13 @@ class CableTraceView(PermissionRequiredMixin, View): def get(self, request, model, pk): obj = get_object_or_404(model, pk=pk) + trace = obj.trace(follow_circuits=True) + total_length = sum([entry[1]._abs_length for entry in trace if entry[1] and entry[1]._abs_length]) return render(request, 'dcim/cable_trace.html', { 'obj': obj, - 'trace': obj.trace(follow_circuits=True), + 'trace': trace, + 'total_length': total_length, }) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 8f9b64b59..a67ff4a86 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -177,6 +177,12 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): # Clear host bits from prefix self.prefix = self.prefix.cidr + # /0 masks are not acceptable + if self.prefix.prefixlen == 0: + raise ValidationError({ + 'prefix': "Cannot create aggregate with /0 mask." + }) + # Ensure that the aggregate being added is not covered by an existing aggregate covering_aggregates = Aggregate.objects.filter(prefix__net_contains_or_equals=str(self.prefix)) if self.pk: @@ -347,6 +353,12 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): if self.prefix: + # /0 masks are not acceptable + if self.prefix.prefixlen == 0: + raise ValidationError({ + 'prefix': "Cannot create prefix with /0 mask." + }) + # Disallow host masks if self.prefix.version == 4 and self.prefix.prefixlen == 32: raise ValidationError({ @@ -622,6 +634,12 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): if self.address: + # /0 masks are not acceptable + if self.address.prefixlen == 0: + raise ValidationError({ + 'address': "Cannot create IP address with /0 mask." + }) + # Enforce unique IP space (if applicable) if self.role not in IPADDRESS_ROLES_NONUNIQUE and (( self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 26df90c93..780bd51df 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -686,7 +686,14 @@ class IPAddressView(PermissionRequiredMixin, View): ).filter( vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address) ) - related_ips_table = tables.IPAddressTable(list(related_ips), orderable=False) + + related_ips_table = tables.IPAddressTable(related_ips, orderable=False) + + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(related_ips_table) return render(request, 'ipam/ipaddress.html', { 'ipaddress': ipaddress, diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 43c722ae5..b7dbb1cfa 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -7,7 +7,7 @@ $(document).ready(function() { // "Toggle" checkbox for object lists (PK column) $('input:checkbox.toggle').click(function() { - $(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked')); + $(this).closest('table').find('input:checkbox[name=pk]:visible').prop('checked', $(this).prop('checked')); // Show the "select all" box if present if ($(this).is(':checked')) { @@ -400,8 +400,8 @@ $(document).ready(function() { window.addEventListener('hashchange', headerOffsetScroll); // Offset between the preview window and the window edges - const IMAGE_PREVIEW_OFFSET_X = 20 - const IMAGE_PREVIEW_OFFSET_Y = 10 + const IMAGE_PREVIEW_OFFSET_X = 20; + const IMAGE_PREVIEW_OFFSET_Y = 10; // Preview an image attachment when the link is hovered over $('a.image-preview').on('mouseover', function(e) { @@ -435,6 +435,6 @@ $(document).ready(function() { // Fade the image out; it will be deleted when another one is previewed $('a.image-preview').on('mouseout', function() { - $('#image-preview-window').fadeOut('fast') + $('#image-preview-window').fadeOut('fast'); }); }); diff --git a/netbox/project-static/js/interface_toggles.js b/netbox/project-static/js/interface_toggles.js new file mode 100644 index 000000000..a3649558a --- /dev/null +++ b/netbox/project-static/js/interface_toggles.js @@ -0,0 +1,30 @@ +// Toggle the display of IP addresses under interfaces +$('button.toggle-ips').click(function() { + var selected = $(this).attr('selected'); + if (selected) { + $('#interfaces_table tr.ipaddresses').hide(); + } else { + $('#interfaces_table tr.ipaddresses').show(); + } + $(this).attr('selected', !selected); + $(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked'); + return false; +}); + +// Inteface filtering +$('input.interface-filter').on('input', function() { + var filter = new RegExp(this.value); + + for (interface of $(this).closest('form').find('tbody > tr')) { + // Slice off 'interface_' at the start of the ID + if (filter && filter.test(interface.id.slice(10))) { + // Match the toggle in case the filter now matches the interface + $(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked')); + $(interface).show(); + } else { + // Uncheck to prevent actions from including it when it doesn't match + $(interface).find('input:checkbox[name=pk]').prop('checked', false); + $(interface).hide(); + } + } +}); diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index a83a5337a..178e488d8 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -125,58 +125,7 @@
Circuit ID | -Type | -Tenant | -A Side | -Z Side | -Description | -
---|---|---|---|---|---|
- {{ c.cid }} - | -- {{ c.type }} - | -- {% if c.tenant %} - {{ c.tenant }} - {% else %} - — - {% endif %} - | -- {% if c.termination_a %} - {{ c.termination_a.site }} - {% else %} - — - {% endif %} - | -- {% if c.termination_z %} - {{ c.termination_z.site }} - {% else %} - — - {% endif %} - | -- {% if c.description %} - {{ c.description }} - {% else %} - — - {% endif %} - | -
None | -