diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 2a1ecd5d0..318a9b7ad 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -17,7 +17,7 @@ body: What version of NetBox are you currently running? (If you don't have access to the most recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/) before opening a bug report to see if your issue has already been addressed.) - placeholder: v3.0.7 + placeholder: v3.0.8 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 6a3f81e1e..be89acfad 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.0.7 + placeholder: v3.0.8 validations: required: true - type: dropdown diff --git a/docs/customization/custom-links.md b/docs/customization/custom-links.md deleted file mode 100644 index 1ee366cfd..000000000 --- a/docs/customization/custom-links.md +++ /dev/null @@ -1 +0,0 @@ -{!models/extras/customlink.md!} diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 25295b621..69d8b8456 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -1,6 +1,27 @@ # NetBox v3.0 -## v3.0.8 (FUTURE) +## v3.0.9 (FUTURE) + +--- + +## v3.0.8 (2021-10-20) + +### Enhancements + +* [#7551](https://github.com/netbox-community/netbox/issues/7551) - Add UI field to filter interfaces by kind +* [#7561](https://github.com/netbox-community/netbox/issues/7561) - Add a utilization column to the IP ranges table + +### Bug Fixes + +* [#7300](https://github.com/netbox-community/netbox/issues/7300) - Fix incorrect Device LLDP interface row coloring +* [#7495](https://github.com/netbox-community/netbox/issues/7495) - Fix navigation UI issue that caused improper element overlap +* [#7529](https://github.com/netbox-community/netbox/issues/7529) - Restore horizontal scrolling for tables in narrow viewports +* [#7534](https://github.com/netbox-community/netbox/issues/7534) - Avoid exception when utilizing "create and add another" twice in succession +* [#7544](https://github.com/netbox-community/netbox/issues/7544) - Fix multi-value filtering of custom field objects +* [#7545](https://github.com/netbox-community/netbox/issues/7545) - Fix incorrect display of update/delete events for webhooks +* [#7550](https://github.com/netbox-community/netbox/issues/7550) - Fix rendering of UTF8-encoded data in change records +* [#7556](https://github.com/netbox-community/netbox/issues/7556) - Fix display of version when new release is available +* [#7584](https://github.com/netbox-community/netbox/issues/7584) - Fix alignment of object identifier under object view --- diff --git a/mkdocs.yml b/mkdocs.yml index 72750d6f5..ce660285f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,7 +66,7 @@ nav: - Customization: - Custom Fields: 'customization/custom-fields.md' - Custom Validation: 'customization/custom-validation.md' - - Custom Links: 'customization/custom-links.md' + - Custom Links: 'models/extras/customlink.md' - Export Templates: 'customization/export-templates.md' - Custom Scripts: 'customization/custom-scripts.md' - Reports: 'customization/reports.md' diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index a4c3cb983..2a7ed8b89 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -704,6 +704,18 @@ class PowerOutletFeedLegChoices(ChoiceSet): # Interfaces # +class InterfaceKindChoices(ChoiceSet): + KIND_PHYSICAL = 'physical' + KIND_VIRTUAL = 'virtual' + KIND_WIRELESS = 'wireless' + + CHOICES = ( + (KIND_PHYSICAL, 'Physical'), + (KIND_VIRTUAL, 'Virtual'), + (KIND_WIRELESS, 'Wireless'), + ) + + class InterfaceTypeChoices(ChoiceSet): # Virtual diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 501e78b18..0ee08bc77 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -7,7 +7,6 @@ from dcim.constants import * from dcim.models import * from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm from tenancy.forms import TenancyFilterForm -from tenancy.models import Tenant from utilities.forms import ( APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, @@ -966,9 +965,14 @@ class InterfaceFilterForm(DeviceComponentFilterForm): model = Interface field_groups = [ ['q', 'tag'], - ['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'], + ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], ] + kind = forms.MultipleChoiceField( + choices=InterfaceKindChoices, + required=False, + widget=StaticSelectMultiple() + ) type = forms.MultipleChoiceField( choices=InterfaceTypeChoices, required=False, diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 25fd32f0d..af8d904f4 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -15,6 +15,7 @@ from .models import * __all__ = ( 'ConfigContextFilterSet', 'ContentTypeFilterSet', + 'CustomFieldFilterSet', 'CustomLinkFilterSet', 'ExportTemplateFilterSet', 'ImageAttachmentFilterSet', @@ -47,7 +48,7 @@ class WebhookFilterSet(BaseFilterSet): ] -class CustomFieldFilterSet(django_filters.FilterSet): +class CustomFieldFilterSet(BaseFilterSet): content_types = ContentTypeFilter() class Meta: diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 485e4a123..ddad6c573 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -260,11 +260,16 @@ class IPRangeTable(BaseTable): linkify=True ) tenant = TenantColumn() + utilization = UtilizationColumn( + accessor='utilization', + orderable=False + ) class Meta(BaseTable.Meta): model = IPRange fields = ( 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', + 'utilization', ) default_columns = ( 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3df9a855a..35e0c6714 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '3.0.8-dev' +VERSION = '3.0.9-dev' # Hostname HOSTNAME = platform.node() diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index 3568204fe..2c033e760 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -137,7 +137,7 @@ class HomeView(View): release_version, release_url = latest_release if release_version > version.parse(settings.VERSION): new_release = { - 'version': str(latest_release), + 'version': str(release_version), 'url': release_url, } diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 4baf2e0e9..75e978e2a 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -282,11 +282,11 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): messages.success(request, mark_safe(msg)) if '_addanother' in request.POST: - redirect_url = request.get_full_path() + redirect_url = request.path # If the object has clone_fields, pre-populate a new instance of the form if hasattr(obj, 'clone_fields'): - redirect_url += f"{'&' if '?' in redirect_url else '?'}{prepare_cloned_fields(obj)}" + redirect_url += f"?{prepare_cloned_fields(obj)}" return redirect(redirect_url) diff --git a/netbox/project-static/dist/lldp.js b/netbox/project-static/dist/lldp.js index 7fac1012a..2b3934742 100644 Binary files a/netbox/project-static/dist/lldp.js and b/netbox/project-static/dist/lldp.js differ diff --git a/netbox/project-static/dist/lldp.js.map b/netbox/project-static/dist/lldp.js.map index 911cd77c3..25d5fc87e 100644 Binary files a/netbox/project-static/dist/lldp.js.map and b/netbox/project-static/dist/lldp.js.map differ diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index 48745de12..b06cca0a1 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index 6ca1d7884..cf06883a9 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index a159c81ec..7e565c3d5 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index cc12e4855..6a60ff56d 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index a67c6cbd8..ba7d8cd2f 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/device/lldp.ts b/netbox/project-static/src/device/lldp.ts index 6baaa9b38..ebf71138c 100644 --- a/netbox/project-static/src/device/lldp.ts +++ b/netbox/project-static/src/device/lldp.ts @@ -1,6 +1,17 @@ import { createToast } from '../bs'; import { getNetboxData, apiGetBase, hasError, isTruthy, toggleLoader } from '../util'; +// Match an interface name that begins with a capital letter and is followed by at least one other +// alphabetic character, and ends with a forward-slash-separated numeric sequence such as 0/1/2. +const CISCO_IOS_PATTERN = new RegExp(/^([A-Z][A-Za-z]+)[^0-9]*([0-9/]+)$/); + +// Mapping of overrides to default Cisco IOS interface alias behavior (default behavior is to use +// the first two characters). +const CISCO_IOS_OVERRIDES = new Map([ + // Cisco IOS abbreviates 25G (TwentyFiveGigE) interfaces as 'Twe'. + ['TwentyFiveGigE', 'Twe'], +]); + /** * Get an attribute from a row's cell. * @@ -12,6 +23,40 @@ function getData(row: HTMLTableRowElement, query: string, attr: string): string return row.querySelector(query)?.getAttribute(attr) ?? null; } +/** + * Get preconfigured alias for given interface. Primarily for matching long-form Cisco IOS + * interface names with short-form Cisco IOS interface names. For example, `GigabitEthernet0/1/2` + * would become `Gi0/1/2`. + * + * This should probably be replaced with something in the primary application (Django), such as + * a database field attached to given interface types. However, this is a temporary measure to + * replace the functionality of this one-liner: + * + * @see https://github.com/netbox-community/netbox/blob/9cc4992fad2fe04ef0211d998c517414e8871d8c/netbox/templates/dcim/device/lldp_neighbors.html#L69 + * + * @param name Long-form/original interface name. + */ +function getInterfaceAlias(name: string | null): string | null { + if (name === null) { + return name; + } + if (name.match(CISCO_IOS_PATTERN)) { + // Extract the base name and numeric portions of the interface. For example, an input interface + // of `GigabitEthernet0/0/1` would result in an array of `['GigabitEthernet', '0/0/1']`. + const [base, numeric] = (name.match(CISCO_IOS_PATTERN) ?? []).slice(1, 3); + + if (isTruthy(base) && isTruthy(numeric)) { + // Check the override map and use its value if the base name is present in the map. + // Otherwise, use the first two characters of the base name. For example, + // `GigabitEthernet0/0/1` would become `Gi0/0/1`, but `TwentyFiveGigE0/0/1` would become + // `Twe0/0/1`. + const aliasBase = CISCO_IOS_OVERRIDES.get(base) || base.slice(0, 2); + return `${aliasBase}${numeric}`; + } + } + return name; +} + /** * Update row styles based on LLDP neighbor data. */ @@ -23,38 +68,41 @@ function updateRowStyle(data: LLDPNeighborDetail) { if (row !== null) { for (const neighbor of neighbors) { - const cellDevice = row.querySelector('td.device'); - const cellInterface = row.querySelector('td.interface'); - const cDevice = getData(row, 'td.configured_device', 'data'); - const cChassis = getData(row, 'td.configured_chassis', 'data-chassis'); - const cInterface = getData(row, 'td.configured_interface', 'data'); + const deviceCell = row.querySelector('td.device'); + const interfaceCell = row.querySelector('td.interface'); + const configuredDevice = getData(row, 'td.configured_device', 'data'); + const configuredChassis = getData(row, 'td.configured_chassis', 'data-chassis'); + const configuredIface = getData(row, 'td.configured_interface', 'data'); - let cInterfaceShort = null; - if (isTruthy(cInterface)) { - cInterfaceShort = cInterface.replace(/^([A-Z][a-z])[^0-9]*([0-9/]+)$/, '$1$2'); + const interfaceAlias = getInterfaceAlias(configuredIface); + + const remoteName = neighbor.remote_system_name ?? ''; + const remotePort = neighbor.remote_port ?? ''; + const [neighborDevice] = remoteName.split('.'); + const [neighborIface] = remotePort.split('.'); + + if (deviceCell !== null) { + deviceCell.innerText = neighborDevice; } - const nHost = neighbor.remote_system_name ?? ''; - const nPort = neighbor.remote_port ?? ''; - const [nDevice] = nHost.split('.'); - const [nInterface] = nPort.split('.'); - - if (cellDevice !== null) { - cellDevice.innerText = nDevice; + if (interfaceCell !== null) { + interfaceCell.innerText = neighborIface; } - if (cellInterface !== null) { - cellInterface.innerText = nInterface; - } + // Interface has an LLDP neighbor, but the neighbor is not configured in NetBox. + const nonConfiguredDevice = !isTruthy(configuredDevice) && isTruthy(neighborDevice); - if (!isTruthy(cDevice) && isTruthy(nDevice)) { + // NetBox device or chassis matches LLDP neighbor. + const validNode = + configuredDevice === neighborDevice || configuredChassis === neighborDevice; + + // NetBox configured interface matches LLDP neighbor interface. + const validInterface = + configuredIface === neighborIface || interfaceAlias === neighborIface; + + if (nonConfiguredDevice) { row.classList.add('info'); - } else if ( - (cDevice === nDevice || cChassis === nDevice) && - cInterfaceShort === nInterface - ) { - row.classList.add('success'); - } else if (cDevice === nDevice || cChassis === nDevice) { + } else if (validNode && validInterface) { row.classList.add('success'); } else { row.classList.add('danger'); diff --git a/netbox/project-static/src/sidenav.ts b/netbox/project-static/src/sidenav.ts index 34c897044..d8207c9f7 100644 --- a/netbox/project-static/src/sidenav.ts +++ b/netbox/project-static/src/sidenav.ts @@ -266,10 +266,8 @@ class SideNav { for (const link of this.getActiveLinks()) { this.activateLink(link, 'collapse'); } - setTimeout(() => { - this.bodyRemove('hide'); - this.bodyAdd('hidden'); - }, 300); + this.bodyRemove('hide'); + this.bodyAdd('hidden'); } } diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index bd081f569..8ce526985 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -197,9 +197,15 @@ table { text-decoration: underline; } } + .dropdown { + // Presence of 'overflow: scroll' on a table causes dropdowns to be improperly hidden when + // opened. See: https://github.com/twbs/bootstrap/issues/24251 + position: static; + } } th { - a, a:hover { + a, + a:hover { color: $body-color; text-decoration: none; } diff --git a/netbox/project-static/styles/sidenav.scss b/netbox/project-static/styles/sidenav.scss index ffc366c16..9dfdd855a 100644 --- a/netbox/project-static/styles/sidenav.scss +++ b/netbox/project-static/styles/sidenav.scss @@ -105,6 +105,11 @@ // Navbar brand .sidenav-brand { margin-right: 0; + transition: opacity 0.1s ease-in-out; + } + + .sidenav-brand-icon { + transition: opacity 0.1s ease-in-out; } .sidenav-inner { @@ -141,7 +146,17 @@ } .sidenav-toggle { - display: none; + // The sidenav toggle's default state is "hidden". Because modifying the `display` property + // isn't ideal for smooth transitions, combine opacity 0 (transparent) and position absolute + // to yield a similar result. + position: absolute; + display: inline-block; + opacity: 0; + // The transition itself is largely irrelevant, but CSS needs *something* to transition in + // order to apply a delay. + transition: opacity 10ms ease-in-out; + // Offset the transition delay so the icon isn't visible during the logo transition. + transition-delay: 0.1s; } .sidenav-collapse { @@ -350,13 +365,21 @@ .sidenav-brand { position: absolute; opacity: 0; - transform: translateX(-150%); } .sidenav-brand-icon { opacity: 1; } + .sidenav-toggle { + // Immediately hide the toggle when the sidenav is closed, so it doesn't linger and overlap + // with the logo elements. + opacity: 0; + position: absolute; + transition: unset; + transition-delay: 0ms; + } + .navbar-nav > .nav-item { > .nav-link { &:after { @@ -402,7 +425,8 @@ @include media-breakpoint-up(lg) { .sidenav-toggle { - display: inline-block; + position: relative; + opacity: 1; } } } diff --git a/netbox/project-static/styles/theme-dark.scss b/netbox/project-static/styles/theme-dark.scss index c7c0cd76e..c5fb5dcf1 100644 --- a/netbox/project-static/styles/theme-dark.scss +++ b/netbox/project-static/styles/theme-dark.scss @@ -74,6 +74,7 @@ $btn-link-disabled-color: $gray-300; // Forms $component-active-bg: $primary; +$component-active-color: $black; $form-text-color: $text-muted; $input-bg: $gray-900; $input-disabled-bg: $gray-700; diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 4239f9eb2..77db7ed18 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -143,7 +143,7 @@
-
diff --git a/netbox/templates/extras/webhook.html b/netbox/templates/extras/webhook.html index f1cf876c1..c92ec4c99 100644 --- a/netbox/templates/extras/webhook.html +++ b/netbox/templates/extras/webhook.html @@ -47,7 +47,7 @@ Update - {% if object.type_create %} + {% if object.type_update %} {% else %} @@ -57,7 +57,7 @@ Delete - {% if object.type_create %} + {% if object.type_delete %} {% else %} diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index 24285846f..40c0e09ce 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -6,9 +6,17 @@ {% load plugins %} {% block header %} - {# Breadcrumbs #} - +
{{ block.super }} {% endblock %} diff --git a/netbox/templates/inc/table.html b/netbox/templates/inc/table.html index 3710af846..c38f50222 100644 --- a/netbox/templates/inc/table.html +++ b/netbox/templates/inc/table.html @@ -1,41 +1,43 @@ {% load django_tables2 %} - +
+ {% if table.show_header %} - - - {% for column in table.columns %} - {% if column.orderable %} - {{ column.header }} - {% else %} - {{ column.header }} - {% endif %} - {% endfor %} - - + + + {% for column in table.columns %} + {% if column.orderable %} + {{ column.header }} + {% else %} + {{ column.header }} + {% endif %} + {% endfor %} + + {% endif %} - {% for row in table.page.object_list|default:table.rows %} - - {% for column, cell in row.items %} - {{ cell }} - {% endfor %} - - {% empty %} - {% if table.empty_text %} - - — {{ table.empty_text }} — - - {% endif %} - {% endfor %} + {% for row in table.page.object_list|default:table.rows %} + + {% for column, cell in row.items %} + {{ cell }} + {% endfor %} + + {% empty %} + {% if table.empty_text %} + + — {{ table.empty_text }} — + + {% endif %} + {% endfor %} {% if table.has_footer %} - - - {% for column in table.columns %} - {{ column.footer }} - {% endfor %} - - + + + {% for column in table.columns %} + {{ column.footer }} + {% endfor %} + + {% endif %} - + +
diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index a900d59e2..1695c8257 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -58,7 +58,7 @@ def render_json(value): """ Render a dictionary as formatted JSON. """ - return json.dumps(value, indent=4, sort_keys=True) + return json.dumps(value, ensure_ascii=False, indent=4, sort_keys=True) @register.filter() diff --git a/requirements.txt b/requirements.txt index 8aa3b8a5c..7cad262b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,11 +18,11 @@ gunicorn==20.1.0 Jinja2==3.0.2 Markdown==3.3.4 markdown-include==0.6.0 -mkdocs-material==7.3.2 +mkdocs-material==7.3.4 netaddr==0.8.0 -Pillow==8.3.2 +Pillow==8.4.0 psycopg2-binary==2.9.1 -PyYAML==5.4.1 +PyYAML==6.0 svgwrite==1.4.1 tablib==3.0.0