Merge branch 'develop' into feature

This commit is contained in:
jeremystretch 2021-10-20 10:10:10 -04:00
commit efb41b7433
31 changed files with 223 additions and 99 deletions

View File

@ -17,7 +17,7 @@ body:
What version of NetBox are you currently running? (If you don't have access to the most 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/) 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.) before opening a bug report to see if your issue has already been addressed.)
placeholder: v3.0.7 placeholder: v3.0.8
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.0.7 placeholder: v3.0.8
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -1 +0,0 @@
{!models/extras/customlink.md!}

View File

@ -1,6 +1,27 @@
# NetBox v3.0 # 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
--- ---

View File

@ -66,7 +66,7 @@ nav:
- Customization: - Customization:
- Custom Fields: 'customization/custom-fields.md' - Custom Fields: 'customization/custom-fields.md'
- Custom Validation: 'customization/custom-validation.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' - Export Templates: 'customization/export-templates.md'
- Custom Scripts: 'customization/custom-scripts.md' - Custom Scripts: 'customization/custom-scripts.md'
- Reports: 'customization/reports.md' - Reports: 'customization/reports.md'

View File

@ -704,6 +704,18 @@ class PowerOutletFeedLegChoices(ChoiceSet):
# Interfaces # 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): class InterfaceTypeChoices(ChoiceSet):
# Virtual # Virtual

View File

@ -7,7 +7,6 @@ from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
from tenancy.forms import TenancyFilterForm from tenancy.forms import TenancyFilterForm
from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect, APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect,
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
@ -966,9 +965,14 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
model = Interface model = Interface
field_groups = [ field_groups = [
['q', 'tag'], ['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'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
] ]
kind = forms.MultipleChoiceField(
choices=InterfaceKindChoices,
required=False,
widget=StaticSelectMultiple()
)
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
choices=InterfaceTypeChoices, choices=InterfaceTypeChoices,
required=False, required=False,

View File

@ -15,6 +15,7 @@ from .models import *
__all__ = ( __all__ = (
'ConfigContextFilterSet', 'ConfigContextFilterSet',
'ContentTypeFilterSet', 'ContentTypeFilterSet',
'CustomFieldFilterSet',
'CustomLinkFilterSet', 'CustomLinkFilterSet',
'ExportTemplateFilterSet', 'ExportTemplateFilterSet',
'ImageAttachmentFilterSet', 'ImageAttachmentFilterSet',
@ -47,7 +48,7 @@ class WebhookFilterSet(BaseFilterSet):
] ]
class CustomFieldFilterSet(django_filters.FilterSet): class CustomFieldFilterSet(BaseFilterSet):
content_types = ContentTypeFilter() content_types = ContentTypeFilter()
class Meta: class Meta:

View File

@ -260,11 +260,16 @@ class IPRangeTable(BaseTable):
linkify=True linkify=True
) )
tenant = TenantColumn() tenant = TenantColumn()
utilization = UtilizationColumn(
accessor='utilization',
orderable=False
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = IPRange model = IPRange
fields = ( fields = (
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
'utilization',
) )
default_columns = ( default_columns = (
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup # Environment setup
# #
VERSION = '3.0.8-dev' VERSION = '3.0.9-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()

View File

@ -137,7 +137,7 @@ class HomeView(View):
release_version, release_url = latest_release release_version, release_url = latest_release
if release_version > version.parse(settings.VERSION): if release_version > version.parse(settings.VERSION):
new_release = { new_release = {
'version': str(latest_release), 'version': str(release_version),
'url': release_url, 'url': release_url,
} }

View File

@ -282,11 +282,11 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
messages.success(request, mark_safe(msg)) messages.success(request, mark_safe(msg))
if '_addanother' in request.POST: 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 the object has clone_fields, pre-populate a new instance of the form
if hasattr(obj, 'clone_fields'): 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) return redirect(redirect_url)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,17 @@
import { createToast } from '../bs'; import { createToast } from '../bs';
import { getNetboxData, apiGetBase, hasError, isTruthy, toggleLoader } from '../util'; 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<string, string>([
// Cisco IOS abbreviates 25G (TwentyFiveGigE) interfaces as 'Twe'.
['TwentyFiveGigE', 'Twe'],
]);
/** /**
* Get an attribute from a row's cell. * 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; 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. * Update row styles based on LLDP neighbor data.
*/ */
@ -23,38 +68,41 @@ function updateRowStyle(data: LLDPNeighborDetail) {
if (row !== null) { if (row !== null) {
for (const neighbor of neighbors) { for (const neighbor of neighbors) {
const cellDevice = row.querySelector<HTMLTableCellElement>('td.device'); const deviceCell = row.querySelector<HTMLTableCellElement>('td.device');
const cellInterface = row.querySelector<HTMLTableCellElement>('td.interface'); const interfaceCell = row.querySelector<HTMLTableCellElement>('td.interface');
const cDevice = getData(row, 'td.configured_device', 'data'); const configuredDevice = getData(row, 'td.configured_device', 'data');
const cChassis = getData(row, 'td.configured_chassis', 'data-chassis'); const configuredChassis = getData(row, 'td.configured_chassis', 'data-chassis');
const cInterface = getData(row, 'td.configured_interface', 'data'); const configuredIface = getData(row, 'td.configured_interface', 'data');
let cInterfaceShort = null; const interfaceAlias = getInterfaceAlias(configuredIface);
if (isTruthy(cInterface)) {
cInterfaceShort = cInterface.replace(/^([A-Z][a-z])[^0-9]*([0-9/]+)$/, '$1$2'); 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 ?? ''; if (interfaceCell !== null) {
const nPort = neighbor.remote_port ?? ''; interfaceCell.innerText = neighborIface;
const [nDevice] = nHost.split('.');
const [nInterface] = nPort.split('.');
if (cellDevice !== null) {
cellDevice.innerText = nDevice;
} }
if (cellInterface !== null) { // Interface has an LLDP neighbor, but the neighbor is not configured in NetBox.
cellInterface.innerText = nInterface; 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'); row.classList.add('info');
} else if ( } else if (validNode && validInterface) {
(cDevice === nDevice || cChassis === nDevice) &&
cInterfaceShort === nInterface
) {
row.classList.add('success');
} else if (cDevice === nDevice || cChassis === nDevice) {
row.classList.add('success'); row.classList.add('success');
} else { } else {
row.classList.add('danger'); row.classList.add('danger');

View File

@ -266,10 +266,8 @@ class SideNav {
for (const link of this.getActiveLinks()) { for (const link of this.getActiveLinks()) {
this.activateLink(link, 'collapse'); this.activateLink(link, 'collapse');
} }
setTimeout(() => {
this.bodyRemove('hide'); this.bodyRemove('hide');
this.bodyAdd('hidden'); this.bodyAdd('hidden');
}, 300);
} }
} }

View File

@ -197,9 +197,15 @@ table {
text-decoration: underline; 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 { th {
a, a:hover { a,
a:hover {
color: $body-color; color: $body-color;
text-decoration: none; text-decoration: none;
} }

View File

@ -105,6 +105,11 @@
// Navbar brand // Navbar brand
.sidenav-brand { .sidenav-brand {
margin-right: 0; margin-right: 0;
transition: opacity 0.1s ease-in-out;
}
.sidenav-brand-icon {
transition: opacity 0.1s ease-in-out;
} }
.sidenav-inner { .sidenav-inner {
@ -141,7 +146,17 @@
} }
.sidenav-toggle { .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 { .sidenav-collapse {
@ -350,13 +365,21 @@
.sidenav-brand { .sidenav-brand {
position: absolute; position: absolute;
opacity: 0; opacity: 0;
transform: translateX(-150%);
} }
.sidenav-brand-icon { .sidenav-brand-icon {
opacity: 1; 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 { .navbar-nav > .nav-item {
> .nav-link { > .nav-link {
&:after { &:after {
@ -402,7 +425,8 @@
@include media-breakpoint-up(lg) { @include media-breakpoint-up(lg) {
.sidenav-toggle { .sidenav-toggle {
display: inline-block; position: relative;
opacity: 1;
} }
} }
} }

View File

@ -74,6 +74,7 @@ $btn-link-disabled-color: $gray-300;
// Forms // Forms
$component-active-bg: $primary; $component-active-bg: $primary;
$component-active-color: $black;
$form-text-color: $text-muted; $form-text-color: $text-muted;
$input-bg: $gray-900; $input-bg: $gray-900;
$input-disabled-bg: $gray-700; $input-disabled-bg: $gray-700;

View File

@ -143,7 +143,7 @@
</div> </div>
<div class="row my-3"> <div class="row my-3">
<div class="col col-md-12"> <div class="col col-md-12">
<ul class="nav nav-pills" role="tablist"> <ul class="nav nav-pills mb-1" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-target="#interfaces" role="tab" data-bs-toggle="tab"> <button class="nav-link active" data-bs-target="#interfaces" role="tab" data-bs-toggle="tab">
Interfaces {% badge interface_table.rows|length %} Interfaces {% badge interface_table.rows|length %}

View File

@ -47,7 +47,7 @@
<tr> <tr>
<th scope="row">Update</th> <th scope="row">Update</th>
<td> <td>
{% if object.type_create %} {% if object.type_update %}
<i class="mdi mdi-check-bold text-success" title="Yes"></i> <i class="mdi mdi-check-bold text-success" title="Yes"></i>
{% else %} {% else %}
<i class="mdi mdi-close-thick text-danger" title="No"></i> <i class="mdi mdi-close-thick text-danger" title="No"></i>
@ -57,7 +57,7 @@
<tr> <tr>
<th scope="row">Delete</th> <th scope="row">Delete</th>
<td> <td>
{% if object.type_create %} {% if object.type_delete %}
<i class="mdi mdi-check-bold text-success" title="Yes"></i> <i class="mdi mdi-check-bold text-success" title="Yes"></i>
{% else %} {% else %}
<i class="mdi mdi-close-thick text-danger" title="No"></i> <i class="mdi mdi-close-thick text-danger" title="No"></i>

View File

@ -6,9 +6,17 @@
{% load plugins %} {% load plugins %}
{% block header %} {% block header %}
<div class="d-flex justify-content-between align-items-center">
{# Breadcrumbs #} {# Breadcrumbs #}
<nav class="breadcrumb-container px-3" aria-label="breadcrumb"> <nav class="breadcrumb-container px-3" aria-label="breadcrumb">
<div class="float-end"> <ol class="breadcrumb">
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url object|viewname:'list' %}">{{ object|meta:'verbose_name_plural'|bettertitle }}</a></li>
{% endblock breadcrumbs %}
</ol>
</nav>
{# Object identifier #}
<div class="float-end px-3">
<code class="text-muted"> <code class="text-muted">
{% block object_identifier %} {% block object_identifier %}
{{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }} {{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }}
@ -16,12 +24,7 @@
{% endblock object_identifier %} {% endblock object_identifier %}
</code> </code>
</div> </div>
<ol class="breadcrumb"> </div>
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url object|viewname:'list' %}">{{ object|meta:'verbose_name_plural'|bettertitle }}</a></li>
{% endblock breadcrumbs %}
</ol>
</nav>
{{ block.super }} {{ block.super }}
{% endblock %} {% endblock %}

View File

@ -1,6 +1,7 @@
{% load django_tables2 %} {% load django_tables2 %}
<table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %}> <div class="table-responsive">
<table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %}>
{% if table.show_header %} {% if table.show_header %}
<thead> <thead>
<tr> <tr>
@ -38,4 +39,5 @@
</tr> </tr>
</tfoot> </tfoot>
{% endif %} {% endif %}
</table> </table>
</div>

View File

@ -58,7 +58,7 @@ def render_json(value):
""" """
Render a dictionary as formatted JSON. 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() @register.filter()

View File

@ -18,11 +18,11 @@ gunicorn==20.1.0
Jinja2==3.0.2 Jinja2==3.0.2
Markdown==3.3.4 Markdown==3.3.4
markdown-include==0.6.0 markdown-include==0.6.0
mkdocs-material==7.3.2 mkdocs-material==7.3.4
netaddr==0.8.0 netaddr==0.8.0
Pillow==8.3.2 Pillow==8.4.0
psycopg2-binary==2.9.1 psycopg2-binary==2.9.1
PyYAML==5.4.1 PyYAML==6.0
svgwrite==1.4.1 svgwrite==1.4.1
tablib==3.0.0 tablib==3.0.0