diff --git a/.travis.yml b/.travis.yml index b23c9d8fc..1576da4cf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,9 +9,7 @@ env: language: python python: - "2.7" - - "3.4" - "3.5" - - "3.6" install: - pip install -r requirements.txt - pip install pep8 diff --git a/README.md b/README.md index 66c35250b..f2b430929 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +**The [2017 NetBox User Survey](https://goo.gl/forms/75HnNS2iE0Y1hVFH3) is open!** Please consider taking a moment to respond. Your feedback helps shape the pace and focus of NetBox development. The survey will remain open until 2017-03-31. Results will be published on the mailing list. + +--- + ![NetBox](docs/netbox_logo.png "NetBox logo") NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md index 58da76cee..ec424fec2 100644 --- a/docs/data-model/extras.md +++ b/docs/data-model/extras.md @@ -90,6 +90,22 @@ NetBox does not have the ability to generate graphs natively, but this feature a * **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`. * **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`. +## Examples + +You only need to define one graph object for each graph you want to include when viewing an object. For example, if you want to include a graph of traffic through an interface over the past five minutes, your graph source might looks like this: + +``` +https://my.nms.local/graphs/?node={{ obj.device.name }}&interface={{ obj.name }}&duration=5m +``` + +You can define several graphs to provide multiple contexts when viewing an object. For example: + +``` +https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m +https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=24h +https://my.nms.local/graphs/?type=errors&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m +``` + # Topology Maps NetBox can generate simple topology maps from the physical network connections recorded in its database. First, you'll need to create a topology map definition under the admin UI at Extras > Topology Maps. diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index b07e87068..087512028 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -5,12 +5,13 @@ from django.db.models import Q from dcim.models import Site from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant -from utilities.filters import NullableModelMultipleChoiceFilter +from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter from .models import Provider, Circuit, CircuitType class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', label='Search', @@ -42,6 +43,7 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index eca792a12..bf390e17b 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -5,7 +5,7 @@ from django.db.models import Q from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant -from utilities.filters import NullableModelMultipleChoiceFilter +from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter from .models import ( ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Region, Site, @@ -14,6 +14,7 @@ from .models import ( class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', label='Search', @@ -81,6 +82,7 @@ class RackGroupFilter(django_filters.FilterSet): class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', label='Search', @@ -157,6 +159,7 @@ class RackReservationFilter(django_filters.FilterSet): class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', label='Search', @@ -191,6 +194,7 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', label='Search', @@ -405,6 +409,10 @@ class InterfaceFilter(django_filters.FilterSet): method='filter_type', label='Interface type', ) + mac_address = django_filters.CharFilter( + method='_mac_address', + label='MAC address', + ) class Meta: model = Interface @@ -420,48 +428,73 @@ class InterfaceFilter(django_filters.FilterSet): return queryset.filter(form_factor=IFACE_FF_LAG) return queryset + def _mac_address(self, queryset, name, value): + value = value.strip() + if not value: + return queryset + try: + return queryset.filter(mac_address=value) + except AddrFormatError: + return queryset.none() + class ConsoleConnectionFilter(django_filters.FilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', ) - - class Meta: - model = ConsoleServerPort - fields = [] + device = django_filters.CharFilter( + method='filter_device', + label='Device', + ) def filter_site(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter(cs_port__device__site__slug=value) + def filter_device(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(device__name__icontains=value) | + Q(cs_port__device__name__icontains=value) + ) + class PowerConnectionFilter(django_filters.FilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', ) - - class Meta: - model = PowerOutlet - fields = [] + device = django_filters.CharFilter( + method='filter_device', + label='Device', + ) def filter_site(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter(power_outlet__device__site__slug=value) + def filter_device(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(device__name__icontains=value) | + Q(power_outlet__device__name__icontains=value) + ) + class InterfaceConnectionFilter(django_filters.FilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', ) - - class Meta: - model = InterfaceConnection - fields = [] + device = django_filters.CharFilter( + method='filter_device', + label='Device', + ) def filter_site(self, queryset, name, value): if not value.strip(): @@ -470,3 +503,11 @@ class InterfaceConnectionFilter(django_filters.FilterSet): Q(interface_a__device__site__slug=value) | Q(interface_b__device__site__slug=value) ) + + def filter_device(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(interface_a__device__name__icontains=value) | + Q(interface_b__device__name__icontains=value) + ) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index c79f65d53..0e09adbb2 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -23,7 +23,7 @@ from .models import ( Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, - VIRTUAL_IFACE_TYPES + SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES ) @@ -375,6 +375,21 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')), to_field_name='slug' ) + is_console_server = forms.BooleanField( + required=False, label='Is a console server', widget=forms.CheckboxInput(attrs={'value': 'True'})) + is_pdu = forms.BooleanField( + required=False, label='Is a PDU', widget=forms.CheckboxInput(attrs={'value': 'True'}) + ) + is_network_device = forms.BooleanField( + required=False, label='Is a network device', widget=forms.CheckboxInput(attrs={'value': 'True'}) + ) + subdevice_role = forms.NullBooleanField( + required=False, label='Subdevice role', widget=forms.Select(choices=( + ('', '---------'), + (SUBDEVICE_ROLE_PARENT, 'Parent'), + (SUBDEVICE_ROLE_CHILD, 'Child'), + )) + ) # @@ -1643,14 +1658,17 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') + device = forms.CharField(required=False, label='Device name') class PowerConnectionFilterForm(BootstrapMixin, forms.Form): site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') + device = forms.CharField(required=False, label='Device name') class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') + device = forms.CharField(required=False, label='Device name') # diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 42c6a39bf..6af772fde 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -100,6 +100,10 @@ DEVICE_PRIMARY_IP = """ {{ record.primary_ip4.address.ip|default:"" }} """ +SUBDEVICE_ROLE_TEMPLATE = """ +{% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}—{% endif %} +""" + UTILIZATION_GRAPH = """ {% load helpers %} {% utilization_graph value %} @@ -249,11 +253,18 @@ class DeviceTypeTable(BaseTable): model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type') part_number = tables.Column(verbose_name='Part Number') is_full_depth = tables.BooleanColumn(verbose_name='Full Depth') + is_console_server = tables.BooleanColumn(verbose_name='CS') + is_pdu = tables.BooleanColumn(verbose_name='PDU') + is_network_device = tables.BooleanColumn(verbose_name='Net') + subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role') instance_count = tables.Column(verbose_name='Instances') class Meta(BaseTable.Meta): model = DeviceType - fields = ('pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count') + fields = ( + 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', + 'is_network_device', 'subdevice_role', 'instance_count' + ) # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index cb307324e..246fe06f0 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -90,7 +90,12 @@ class ComponentCreateView(View): self.parent_field: parent.pk, 'name': name, } - component_data.update(data) + # Replace objects with their primary key to keep component_form.clean() happy + for k, v in data.items(): + if hasattr(v, 'pk'): + component_data[k] = v.pk + else: + component_data[k] = v component_form = self.model_form(component_data) if component_form.is_valid(): new_components.append(component_form.save(commit=False)) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 10a18d1b7..3c39a4308 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -7,12 +7,13 @@ from django.db.models import Q from dcim.models import Site, Device, Interface from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant -from utilities.filters import NullableModelMultipleChoiceFilter +from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', label='Search', @@ -44,6 +45,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): class RIRFilter(django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') class Meta: model = RIR @@ -51,6 +53,7 @@ class RIRFilter(django_filters.FilterSet): class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', label='Search', @@ -84,6 +87,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', label='Search', @@ -182,6 +186,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', label='Search', @@ -283,6 +288,7 @@ class VLANGroupFilter(django_filters.FilterSet): class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 66065e9e7..ae81d2689 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.9.2' +VERSION = '1.9.3' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 5f59daad4..f2cab9691 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -4,9 +4,11 @@ from django.db.models import Q from .models import Secret, SecretRole from dcim.models import Device +from utilities.filters import NumericInFilter class SecretFilter(django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 6f0dfced6..0ebc4f5b4 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -295,7 +295,8 @@

Docs · API · - Code + Code · + Help

diff --git a/netbox/templates/dcim/device_import.html b/netbox/templates/dcim/device_import.html index c3915b9c3..50d2f81db 100644 --- a/netbox/templates/dcim/device_import.html +++ b/netbox/templates/dcim/device_import.html @@ -73,7 +73,7 @@ Rack - Rack name + Rack name (optional) R101 diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 86c2e4090..7d7d66c2e 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -35,7 +35,13 @@ {% if peer_termination %} - {{ peer_termination.site }} via + {% if peer_termination.interface %} + {{ peer_termination.interface.device }} + ({{ peer_termination.site }}) + {% else %} + {{ peer_termination.site }} + {% endif %} + via {% endif %} {{ iface.circuit_termination.circuit }} diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index ed1721102..b96345980 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -3,11 +3,12 @@ import django_filters from django.db.models import Q from extras.filters import CustomFieldFilterSet -from utilities.filters import NullableModelMultipleChoiceFilter +from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter from .models import Tenant, TenantGroup class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index d1dbf39b8..c352f0f41 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -6,6 +6,17 @@ from django.db.models import Q from django.utils.encoding import force_text +# +# Filters +# + +class NumericInFilter(django_filters.BaseInFilter, django_filters.NumberFilter): + """ + Filters for a set of numeric values. Example: id__in=100,200,300 + """ + pass + + class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField): """ This field operates like a normal ModelMultipleChoiceField except that it allows for one additional choice which is