diff --git a/.gitignore b/.gitignore index 2f957c678..b33d46a40 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.pyc /netbox/netbox/configuration.py /netbox/netbox/ldap_config.py +/netbox/reports/* +!/netbox/reports/__init__.py /netbox/static .idea /*.sh diff --git a/.travis.yml b/.travis.yml index 1576da4cf..2c22e30b9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,7 @@ sudo: required services: - docker + - postgresql env: - DOCKER_TAG=$TRAVIS_TAG @@ -13,6 +14,8 @@ python: install: - pip install -r requirements.txt - pip install pep8 +addons: + - postgresql: "9.4" script: - ./scripts/cibuild.sh after_success: diff --git a/docs/miscellaneous/reports.md b/docs/miscellaneous/reports.md new file mode 100644 index 000000000..79e4fb085 --- /dev/null +++ b/docs/miscellaneous/reports.md @@ -0,0 +1,119 @@ +# NetBox Reports + +A NetBox report is a mechanism for validating the integrity of data within NetBox. Running a report allows the user to verify that the objects defined within NetBox meet certain arbitrary conditions. For example, you can write reports to check that: + +* All top-of-rack switches have a console connection +* Every router has a loopback interface with an IP address assigned +* Each interface description conforms to a standard format +* Every site has a minimum set of VLANs defined +* All IP addresses have a parent prefix + +...and so on. Reports are completely customizable, so there's practically no limit to what you can test for. + +## Writing Reports + +Reports must be saved as files in the `netbox/reports/` path within the NetBox installation path. Each file created within this path is considered a separate module. Each module holds one or more reports, each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test. + +!!! warning + The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file. + +For example, we can create a module named `devices.py` to hold all of our reports which pertain to devices in NetBox. Within that module, we might define several reports. Each report is defined as a Python class inheriting from `extras.reports.Report`. + +``` +from extras.reports import Report + +class DeviceConnectionsReport(Report): + description = "Validate the minimum physical connections for each device" + +class DeviceIPsReport(Report): + description = "Check that every device has a primary IP address assigned" +``` + +Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections. + +``` +from dcim.constants import CONNECTION_STATUS_PLANNED, STATUS_ACTIVE +from dcim.models import ConsolePort, Device, PowerPort +from extras.reports import Report + + +class DeviceConnectionsReport(Report): + description = "Validate the minimum physical connections for each device" + + def test_console_connection(self): + + # Check that every console port for every active device has a connection defined. + for console_port in ConsolePort.objects.select_related('device').filter(device__status=STATUS_ACTIVE): + if console_port.cs_port is None: + self.log_failure( + console_port.device, + "No console connection defined for {}".format(console_port.name) + ) + elif console_port.connection_status == CONNECTION_STATUS_PLANNED: + self.log_warning( + console_port.device, + "Console connection for {} marked as planned".format(console_port.name) + ) + else: + self.log_success(console_port.device) + + def test_power_connections(self): + + # Check that every active device has at least two connected power supplies. + for device in Device.objects.filter(status=STATUS_ACTIVE): + connected_ports = 0 + for power_port in PowerPort.objects.filter(device=device): + if power_port.power_outlet is not None: + connected_ports += 1 + if power_port.connection_status == CONNECTION_STATUS_PLANNED: + self.log_warning( + device, + "Power connection for {} marked as planned".format(power_port.name) + ) + if connected_ports < 2: + self.log_failure( + device, + "{} connected power supplies found (2 needed)".format(connected_ports) + ) + else: + self.log_success(device) +``` + +As you can see, reports are completely customizable. Validation logic can be as simple or as complex as needed. + +!!! warning + Reports should never alter data: If you find yourself using the `create()`, `save()`, `update()`, or `delete()` methods on objects within reports, stop and re-evaluate what you're trying to accomplish. Note that there are no safeguards against the accidental alteration or destruction of data. + +The following methods are available to log results within a report: + +* log(message) +* log_success(object, message=None) +* log_info(object, message) +* log_warning(object, message) +* log_failure(object, message) + +The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. + +Once you have created a report, it will appear in the reports list. Initially, reports will have no results associated with them. To generate results, run the report. + +## Running Reports + +### Via the Web UI + +Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Note that a user must have permission to create ReportResults in order to run reports. (Permissions can be assigned through the admin UI.) + +Once a report has been run, its associated results will be included in the report view. + +### Via the API + +To run a report via the API, simply issue a POST request. Reports are identified by their module and class name. + +``` + POST /api/extras/reports/./ +``` + +Our example report above would be called as: + +``` + POST /api/extras/reports/devices.DeviceConnectionsReport/ +``` diff --git a/mkdocs.yml b/mkdocs.yml index c26a9ed17..e3ac9f50b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,6 +27,8 @@ pages: - 'Examples': 'api/examples.md' - 'Shell': - 'Introduction': 'shell/intro.md' + - 'Miscellaneous': + - 'Reports': 'miscellaneous/reports.md' - 'Development': - 'Utility Views': 'development/utility-views.md' diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 0eeab49ec..8998b509b 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -7,7 +7,7 @@ from rest_framework import serializers from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer from dcim.models import Device, Rack, Site from extras.models import ( - ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction, + ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, ReportResult, TopologyMap, UserAction, ) from users.api.serializers import NestedUserSerializer from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer @@ -127,6 +127,41 @@ class WritableImageAttachmentSerializer(ValidatedModelSerializer): return data +# +# Reports +# + +class ReportResultSerializer(serializers.ModelSerializer): + + class Meta: + model = ReportResult + fields = ['created', 'user', 'failed', 'data'] + + +class NestedReportResultSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='extras-api:report-detail', + lookup_field='report', + lookup_url_kwarg='pk' + ) + + class Meta: + model = ReportResult + fields = ['url', 'created', 'user', 'failed'] + + +class ReportSerializer(serializers.Serializer): + module = serializers.CharField(max_length=255) + name = serializers.CharField(max_length=255) + description = serializers.CharField(max_length=255, required=False) + test_methods = serializers.ListField(child=serializers.CharField(max_length=255)) + result = NestedReportResultSerializer() + + +class ReportDetailSerializer(ReportSerializer): + result = ReportResultSerializer() + + # # User actions # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index c5268318c..da76e67bd 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -28,6 +28,9 @@ router.register(r'topology-maps', views.TopologyMapViewSet) # Image attachments router.register(r'image-attachments', views.ImageAttachmentViewSet) +# Reports +router.register(r'reports', views.ReportViewSet, base_name='report') + # Recent activity router.register(r'recent-activity', views.RecentActivityViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 37112f2c6..bd1d33fa3 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,14 +1,17 @@ from __future__ import unicode_literals from rest_framework.decorators import detail_route -from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet from django.contrib.contenttypes.models import ContentType -from django.http import HttpResponse +from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from extras import filters -from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction +from extras.models import ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction +from extras.reports import get_report, get_reports from utilities.api import WritableSerializerMixin from . import serializers @@ -88,6 +91,77 @@ class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet): write_serializer_class = serializers.WritableImageAttachmentSerializer +class ReportViewSet(ViewSet): + _ignore_model_permissions = True + exclude_from_schema = True + lookup_value_regex = '[^/]+' # Allow dots + + def _retrieve_report(self, pk): + + # Read the PK as "." + if '.' not in pk: + raise Http404 + module_name, report_name = pk.split('.', 1) + + # Raise a 404 on an invalid Report module/name + report = get_report(module_name, report_name) + if report is None: + raise Http404 + + return report + + def list(self, request): + """ + Compile all reports and their related results (if any). Result data is deferred in the list view. + """ + report_list = [] + + # Iterate through all available Reports. + for module_name, reports in get_reports(): + for report in reports: + + # Attach the relevant ReportResult (if any) to each Report. + report.result = ReportResult.objects.filter(report=report.full_name).defer('data').first() + report_list.append(report) + + serializer = serializers.ReportSerializer(report_list, many=True, context={ + 'request': request, + }) + + return Response(serializer.data) + + def retrieve(self, request, pk): + """ + Retrieve a single Report identified as ".". + """ + + # Retrieve the Report and ReportResult, if any. + report = self._retrieve_report(pk) + report.result = ReportResult.objects.filter(report=report.full_name).first() + + serializer = serializers.ReportDetailSerializer(report) + + return Response(serializer.data) + + @detail_route(methods=['post']) + def run(self, request, pk): + """ + Run a Report and create a new ReportResult, overwriting any previous result for the Report. + """ + + # Check that the user has permission to run reports. + if not request.user.has_perm('extras.add_reportresult'): + raise PermissionDenied("This user does not have permission to run reports.") + + # Retrieve and run the Report. This will create a new ReportResult. + report = self._retrieve_report(pk) + report.run() + + serializer = serializers.ReportDetailSerializer(report) + + return Response(serializer.data) + + class RecentActivityViewSet(ReadOnlyModelViewSet): """ List all UserActions to provide a log of recent activity. diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 99eb779c6..fc5786a64 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -62,3 +62,17 @@ ACTION_CHOICES = ( (ACTION_DELETE, 'deleted'), (ACTION_BULK_DELETE, 'bulk deleted'), ) + +# Report logging levels +LOG_DEFAULT = 0 +LOG_SUCCESS = 10 +LOG_INFO = 20 +LOG_WARNING = 30 +LOG_FAILURE = 40 +LOG_LEVEL_CODES = { + LOG_DEFAULT: 'default', + LOG_SUCCESS: 'success', + LOG_INFO: 'info', + LOG_WARNING: 'warning', + LOG_FAILURE: 'failure', +} diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py new file mode 100644 index 000000000..9adf6b130 --- /dev/null +++ b/netbox/extras/management/commands/runreport.py @@ -0,0 +1,48 @@ +from __future__ import unicode_literals + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from extras.models import ReportResult +from extras.reports import get_reports + + +class Command(BaseCommand): + help = "Run a report to validate data in NetBox" + + def add_arguments(self, parser): + parser.add_argument('reports', nargs='+', help="Report(s) to run") + # parser.add_argument('--verbose', action='store_true', default=False, help="Print all logs") + + def handle(self, *args, **options): + + # Gather all available reports + reports = get_reports() + + # Run reports + for module_name, report_list in reports: + for report in report_list: + if module_name in options['reports'] or report.full_namel in options['reports']: + + # Run the report and create a new ReportResult + self.stdout.write( + "[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name) + ) + report.run() + + # Report on success/failure + status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') + for test_name, attrs in report.result.data.items(): + self.stdout.write( + "\t{}: {} success, {} info, {} warning, {} failure".format( + test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure'] + ) + ) + self.stdout.write( + "[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status) + ) + + # Wrap things up + self.stdout.write( + "[{:%H:%M:%S}] Finished".format(timezone.now()) + ) diff --git a/netbox/extras/migrations/0008_reports.py b/netbox/extras/migrations/0008_reports.py new file mode 100644 index 000000000..c9fc16cc3 --- /dev/null +++ b/netbox/extras/migrations/0008_reports.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-26 21:25 +from __future__ import unicode_literals + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('extras', '0007_unicode_literals'), + ] + + operations = [ + migrations.CreateModel( + name='ReportResult', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('report', models.CharField(max_length=255, unique=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('failed', models.BooleanField()), + ('data', django.contrib.postgres.fields.jsonb.JSONField()), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['report'], + }, + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 4afc3afcf..5181e88e9 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -6,6 +6,7 @@ import graphviz from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import JSONField from django.core.validators import ValidationError from django.db import models from django.db.models import Q @@ -388,6 +389,24 @@ class ImageAttachment(models.Model): return None +# +# Report results +# + +class ReportResult(models.Model): + """ + This model stores the results from running a user-defined report. + """ + report = models.CharField(max_length=255, unique=True) + created = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='+', blank=True, null=True) + failed = models.BooleanField() + data = JSONField() + + class Meta: + ordering = ['report'] + + # # User actions # diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py new file mode 100644 index 000000000..921aab380 --- /dev/null +++ b/netbox/extras/reports.py @@ -0,0 +1,178 @@ +from collections import OrderedDict +import importlib +import inspect +import pkgutil + +from django.utils import timezone + +from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_LEVEL_CODES, LOG_SUCCESS, LOG_WARNING +from .models import ReportResult +import reports as custom_reports + + +def is_report(obj): + """ + Returns True if the given object is a Report. + """ + if obj in Report.__subclasses__(): + return True + return False + + +def get_report(module_name, report_name): + """ + Return a specific report from within a module. + """ + module = importlib.import_module('reports.{}'.format(module_name)) + report = getattr(module, report_name, None) + if report is None: + return None + return report() + + +def get_reports(): + """ + Compile a list of all reports available across all modules in the reports path. Returns a list of tuples: + + [ + (module_name, (report, report, report, ...)), + (module_name, (report, report, report, ...)), + ... + ] + """ + module_list = [] + + # Iterate through all modules within the reports path. These are the user-defined files in which reports are + # defined. + for importer, module_name, is_pkg in pkgutil.walk_packages(custom_reports.__path__): + module = importlib.import_module('reports.{}'.format(module_name)) + report_list = [cls() for _, cls in inspect.getmembers(module, is_report)] + module_list.append((module_name, report_list)) + + return module_list + + +class Report(object): + """ + NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each + report must have one or more test methods named `test_*`. + + The `_results` attribute of a completed report will take the following form: + + { + 'test_bar': { + 'failures': 42, + 'log': [ + (, , , ), + ... + ] + }, + 'test_foo': { + 'failures': 0, + 'log': [ + (, , , ), + ... + ] + } + } + """ + description = None + + def __init__(self): + + self._results = OrderedDict() + self.active_test = None + self.failed = False + + # Compile test methods and initialize results skeleton + test_methods = [] + for method in dir(self): + if method.startswith('test_') and callable(getattr(self, method)): + test_methods.append(method) + self._results[method] = OrderedDict([ + ('success', 0), + ('info', 0), + ('warning', 0), + ('failure', 0), + ('log', []), + ]) + if not test_methods: + raise Exception("A report must contain at least one test method.") + self.test_methods = test_methods + + @property + def module(self): + return self.__module__.rsplit('.', 1)[1] + + @property + def name(self): + return self.__class__.__name__ + + @property + def full_name(self): + return '.'.join([self.module, self.name]) + + def _log(self, obj, message, level=LOG_DEFAULT): + """ + Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below. + """ + if level not in LOG_LEVEL_CODES: + raise Exception("Unknown logging level: {}".format(level)) + self._results[self.active_test]['log'].append(( + timezone.now().isoformat(), + LOG_LEVEL_CODES.get(level), + str(obj) if obj else None, + obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None, + message, + )) + + def log(self, message): + """ + Log a message which is not associated with a particular object. + """ + self._log(None, message, level=LOG_DEFAULT) + + def log_success(self, obj, message=None): + """ + Record a successful test against an object. Logging a message is optional. + """ + if message: + self._log(obj, message, level=LOG_SUCCESS) + self._results[self.active_test]['success'] += 1 + + def log_info(self, obj, message): + """ + Log an informational message. + """ + self._log(obj, message, level=LOG_INFO) + self._results[self.active_test]['info'] += 1 + + def log_warning(self, obj, message): + """ + Log a warning. + """ + self._log(obj, message, level=LOG_WARNING) + self._results[self.active_test]['warning'] += 1 + + def log_failure(self, obj, message): + """ + Log a failure. Calling this method will automatically mark the report as failed. + """ + self._log(obj, message, level=LOG_FAILURE) + self._results[self.active_test]['failure'] += 1 + self.failed = True + + def run(self): + """ + Run the report and return its results. Each test method will be executed in order. + """ + for method_name in self.test_methods: + self.active_test = method_name + test_method = getattr(self, method_name) + test_method() + + # Delete any previous ReportResult and create a new one to record the result. + ReportResult.objects.filter(report=self.full_name).delete() + result = ReportResult(report=self.full_name, failed=self.failed, data=self._results) + result.save() + self.result = result diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index f980158e8..1ac7fce6c 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -12,4 +12,9 @@ urlpatterns = [ url(r'^image-attachments/(?P\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), url(r'^image-attachments/(?P\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + # Reports + url(r'^reports/$', views.ReportListView.as_view(), name='report_list'), + url(r'^reports/(?P[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'), + url(r'^reports/(?P[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'), + ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 881525237..e92b28187 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,13 +1,23 @@ from __future__ import unicode_literals from django.contrib.auth.mixins import PermissionRequiredMixin -from django.shortcuts import get_object_or_404 +from django.contrib import messages +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.safestring import mark_safe +from django.views.generic import View +from utilities.forms import ConfirmationForm from utilities.views import ObjectDeleteView, ObjectEditView from .forms import ImageAttachmentForm -from .models import ImageAttachment +from .models import ImageAttachment, ReportResult, UserAction +from .reports import get_report, get_reports +# +# Image attachments +# + class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'extras.change_imageattachment' model = ImageAttachment @@ -30,3 +40,79 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView): def get_return_url(self, request, imageattachment): return imageattachment.parent.get_absolute_url() + + +# +# Reports +# + +class ReportListView(View): + """ + Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each. + """ + + def get(self, request): + + reports = get_reports() + results = {r.report: r for r in ReportResult.objects.all()} + + ret = [] + for module, report_list in reports: + module_reports = [] + for report in report_list: + report.result = results.get(report.full_name, None) + module_reports.append(report) + ret.append((module, module_reports)) + + return render(request, 'extras/report_list.html', { + 'reports': ret, + }) + + +class ReportView(View): + """ + Display a single Report and its associated ReportResult (if any). + """ + + def get(self, request, name): + + # Retrieve the Report by "." + module_name, report_name = name.split('.') + report = get_report(module_name, report_name) + if report is None: + raise Http404 + + # Attach the ReportResult (if any) + report.result = ReportResult.objects.filter(report=report.full_name).first() + + return render(request, 'extras/report.html', { + 'report': report, + 'run_form': ConfirmationForm(), + }) + + +class ReportRunView(PermissionRequiredMixin, View): + """ + Run a Report and record a new ReportResult. + """ + permission_required = 'extras.add_reportresult' + + def post(self, request, name): + + # Retrieve the Report by "." + module_name, report_name = name.split('.') + report = get_report(module_name, report_name) + if report is None: + raise Http404 + + form = ConfirmationForm(request.POST) + if form.is_valid(): + + # Run the Report. A new ReportResult is created. + report.run() + result = 'failed' if report.failed else 'passed' + msg = "Ran report {} ({})".format(report.full_name, result) + messages.success(request, mark_safe(msg)) + UserAction.objects.log_create(request.user, report.result, msg) + + return redirect('extras:report', name=report.full_name) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index b013aab97..e8167d916 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -339,6 +339,18 @@ table.component-list td.subtable td { padding-top: 6px; } +/* Reports */ +table.reports td.method { + font-family: monospace; + padding-left: 30px; +} +table.reports td.stats label { + display: inline-block; + line-height: 14px; + margin-bottom: 0; + min-width: 40px; +} + /* AJAX loader */ .loading { position: fixed; diff --git a/netbox/reports/__init__.py b/netbox/reports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 3a2cd85d4..c81b12d72 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -28,7 +28,7 @@