Compare commits

..

60 Commits

Author SHA1 Message Date
Jeremy Stretch
2c161c01c1 Merge pull request #7590 from netbox-community/develop
Release v3.0.8
2021-10-20 09:49:15 -04:00
jeremystretch
fc5a23cc88 Release v3.0.8 2021-10-20 09:31:12 -04:00
jeremystretch
73f2f9fc63 Closes #7551: Add UI field to filter interfaces by kind 2021-10-19 15:57:02 -04:00
jeremystretch
eb4b4a6c8d Closes #7561: Add a utilization column to the IP ranges table 2021-10-19 15:51:39 -04:00
jeremystretch
39430e01de Fixes #7550: Fix rendering of UTF8-encoded data in change records 2021-10-19 15:41:19 -04:00
jeremystretch
96015aa590 Fixes #7582: Fix rendering of CustomLink context data table 2021-10-19 15:31:07 -04:00
jeremystretch
c1720505f3 Fixes #7584: Fix alignment of object identifier under object view 2021-10-19 15:22:22 -04:00
Jeremy Stretch
5c338a90a1 Merge pull request #7566 from PieterL75/patch-1
Fix #7556 : NewVersion showing url
2021-10-19 15:20:58 -04:00
PieterL75
79cee12b1e Updated release notes with #7556 2021-10-19 16:23:05 +02:00
PieterL75
aa5c42683a Fix #7556 : NewVersion showing url 2021-10-18 16:12:23 +02:00
thatmattlove
9c6938e7ae Minor Style Improvement: Fix interface table dropdowns being hidden when opened 2021-10-15 17:45:47 -07:00
thatmattlove
811c21ec7e Minor Style Improvement: Add vertical spacing to Device Type component navigation & fix inconsistent component active color 2021-10-15 17:21:36 -07:00
thatmattlove
84c14aadc7 Fixes #7300: Fix incorrect Device LLDP interface row coloring & improve related JS 2021-10-15 17:07:54 -07:00
thatmattlove
f1f0d9cd0d Fixes #7495: Fix sidenav overlapping elements 2021-10-15 15:02:50 -07:00
jeremystretch
e16942dea5 Fixes #7529: Restore horizontal scrolling for tables in narrow viewports 2021-10-14 13:44:54 -04:00
Jeremy Stretch
12efcec3b0 Merge pull request #7546 from miaow2/7545-webhook-events-status
Fixes #7545: Incorrect display of Events status on webhook page
2021-10-14 13:43:00 -04:00
miaow2
a7b6c40596 Fixing display of webhook types 2021-10-14 20:35:21 +03:00
jeremystretch
b95773938d Fixes #7534: Avoid exception when utilizing "create and add another" twice in succession 2021-10-14 12:24:29 -04:00
jeremystretch
6898ae7106 Fixes #7544: Fix multi-value filtering of custom field objects 2021-10-14 11:36:13 -04:00
jeremystretch
1a4f8c5422 PRVB 2021-10-11 14:42:29 -04:00
Jeremy Stretch
66c4d23119 Merge pull request #7510 from netbox-community/develop
Release v3.0.7
2021-10-08 14:04:34 -04:00
jeremystretch
d66fc8f661 Release v3.0.7 2021-10-08 13:49:15 -04:00
jeremystretch
031876964f #2102: Implement q search filter for device type components 2021-10-08 13:42:43 -04:00
jeremystretch
c63766c4c6 Fix test for #7051 2021-10-07 14:19:29 -04:00
jeremystretch
af6237e12e Fixes #7479: Fix parent interface choices when bulk editing VM interfaces 2021-10-07 13:57:00 -04:00
jeremystretch
00328226ec Fixes #7051: Fix permissions evaluation and improve error handling for connected device REST API endpoint 2021-10-07 13:15:59 -04:00
jeremystretch
b31ba4e9d2 Changelog & UI tweaks for #6879 2021-10-07 12:41:24 -04:00
Jeremy Stretch
4be5d3f9e9 Merge pull request #6960 from candlerb/candlerb/6879-v3
Display device names in front of device front/rear images
2021-10-07 12:35:17 -04:00
jeremystretch
53154746fc Changelog for #7485 2021-10-07 10:40:51 -04:00
Jeremy Stretch
2f4c1b6e8f Merge pull request #7475 from HumanEquivalentUnit/patch-1
Mention data in custom fields, link Jinja2 docs.
2021-10-07 10:38:01 -04:00
Jeremy Stretch
045ec7d3a0 Merge pull request #7486 from alexanderhofstaetter/patch-1
Added "USB Micro AB" combo type to choices
2021-10-07 10:35:40 -04:00
jeremystretch
b73db750e5 Fixes #7471: Correct redirect URL when attaching images via "add another" button 2021-10-07 09:58:42 -04:00
jeremystretch
3f766ffea8 Fixes #7474: Fix AttributeError exception when rendering a report or custom script 2021-10-07 09:37:21 -04:00
Alexander Hofstätter
f28761202f Added "USB Micro AB" combo type to choices 2021-10-07 14:31:54 +02:00
HumanEquivalentUnit
6d1f07df05 Mention data in custom fields, link Jinja2 docs.
Resolves #7367
2021-10-07 00:51:07 +01:00
Brian Candler
eb9f2b36ab Display device names in front of device front/rear images
Fixes #6879
2021-10-06 18:07:28 +00:00
jeremystretch
2bd29127dc PRVB 2021-10-06 14:04:24 -04:00
Jeremy Stretch
3eef6363fd Merge pull request #7465 from netbox-community/develop
Release v3.0.6
2021-10-06 14:02:44 -04:00
jeremystretch
d451f30bfc Release v3.0.6 2021-10-06 13:45:02 -04:00
jeremystretch
105956f8e6 Closes #7464: Improve documentation for executing housekeeping task as a cron job 2021-10-06 13:24:13 -04:00
jeremystretch
39256afb67 Closes #7394: Enable filtering cables by termination type & ID in REST API 2021-10-06 12:06:32 -04:00
jeremystretch
69aaf28b9c Closes #6955: Include type, ID, and slug on object view 2021-10-06 11:23:06 -04:00
jeremystretch
b806220074 Closes #6850: Default to current user when creating journal entries via REST API 2021-10-06 10:56:50 -04:00
jeremystretch
d2bdf4e822 Closes #7462: Include count of assigned virtual machines under platform view 2021-10-06 10:12:44 -04:00
jeremystretch
3ab5682e7a Fixes #7460: Fix filtering connections by site ID 2021-10-06 10:08:56 -04:00
jeremystretch
c0010ec100 Fixes #7459: Pre-populate location data when adding a device to a rack 2021-10-06 10:00:31 -04:00
jeremystretch
6897c5fadd Fixes #7455: Fix site/provider network validation for circuit termination API serializer 2021-10-06 09:11:42 -04:00
jeremystretch
745aa23ed6 Fixes #7458: Correct tenants count label under tenant group view 2021-10-06 08:44:59 -04:00
jeremystretch
9089f5cf67 #7450: Clean up object edit forms 2021-10-05 15:37:49 -04:00
jeremystretch
dd79aae137 #7450: Misc UI cleanup 2021-10-05 15:21:49 -04:00
jeremystretch
26e470f521 #7449: Use lighter color for top-level nav menu items 2021-10-05 14:57:35 -04:00
jeremystretch
a34c8b80e5 #7449: Use original primary color 2021-10-05 14:52:10 -04:00
jeremystretch
854a12982f #7449: Lighten dropdown widget caret color 2021-10-05 14:36:33 -04:00
jeremystretch
cf173d4f50 #7449: Remove color from table header links 2021-10-05 14:16:19 -04:00
jeremystretch
7041486b93 #7449: Remove color from panel headers on home view 2021-10-05 14:03:14 -04:00
jeremystretch
548a8c3be3 #7449: Fix login banner color 2021-10-05 14:01:24 -04:00
jeremystretch
087a018faf Fix changelog for v3.0.5 2021-10-05 12:08:07 -04:00
jeremystretch
e09024e86f Fixes #7446: Fix exception when viewing a large number of child IPs within a prefix 2021-10-05 12:07:03 -04:00
jeremystretch
1757102536 Fixes #7442: Fix missing actions column on user-configured tables 2021-10-05 09:34:30 -04:00
jeremystretch
c262af550d PRVB 2021-10-04 14:18:42 -04:00
74 changed files with 741 additions and 533 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
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.5
placeholder: v3.0.8
validations:
required: true
- type: dropdown

View File

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

View File

@@ -5,6 +5,13 @@ NetBox includes a `housekeeping` management command that should be run nightly.
* Clearing expired authentication sessions from the database
* Deleting changelog records older than the configured [retention time](../configuration/optional-settings.md#changelog_retention)
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be copied into your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
The `housekeeping` command can also be run manually at any time: Running the command outside of scheduled execution times will not interfere with its operation.
```shell
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
!!! note
On Debian-based systems, be sure to omit the `.sh` file extension when linking to the script from within a cron directory. Otherwise, the task may not run.
The `housekeeping` command can also be run manually at any time: Running the command outside scheduled execution times will not interfere with its operation.

View File

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

View File

@@ -259,10 +259,10 @@ python3 manage.py createsuperuser
NetBox includes a `housekeeping` management command that handles some recurring cleanup tasks, such as clearing out old sessions and expired change records. Although this command may be run manually, it is recommended to configure a scheduled job using the system's `cron` daemon or a similar utility.
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
```shell
cp /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.

View File

@@ -111,10 +111,10 @@ sudo systemctl restart netbox netbox-rq
## Verify Housekeeping Scheduling
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
```shell
cp /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.

View File

@@ -1,8 +1,8 @@
# Custom Links
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a network monitoring system.
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS).
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object.
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the Netbox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`.
For example, you might define a link like this:

View File

@@ -1,5 +1,61 @@
# NetBox v3.0
## 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
---
## v3.0.7 (2021-10-08)
### Enhancements
* [#6879](https://github.com/netbox-community/netbox/issues/6879) - Improve ability to toggle images/labels in rack elevations
* [#7485](https://github.com/netbox-community/netbox/issues/7485) - Add USB micro AB type
### Bug Fixes
* [#7051](https://github.com/netbox-community/netbox/issues/7051) - Fix permissions evaluation and improve error handling for connected device REST API endpoint
* [#7471](https://github.com/netbox-community/netbox/issues/7471) - Correct redirect URL when attaching images via "add another" button
* [#7474](https://github.com/netbox-community/netbox/issues/7474) - Fix AttributeError exception when rendering a report or custom script
* [#7479](https://github.com/netbox-community/netbox/issues/7479) - Fix parent interface choices when bulk editing VM interfaces
---
## v3.0.6 (2021-10-06)
### Enhancements
* [#6850](https://github.com/netbox-community/netbox/issues/6850) - Default to current user when creating journal entries via REST API
* [#6955](https://github.com/netbox-community/netbox/issues/6955) - Include type, ID, and slug on object view
* [#7394](https://github.com/netbox-community/netbox/issues/7394) - Enable filtering cables by termination type & ID in REST API
* [#7462](https://github.com/netbox-community/netbox/issues/7462) - Include count of assigned virtual machines under platform view
### Bug Fixes
* [#7442](https://github.com/netbox-community/netbox/issues/7442) - Fix missing actions column on user-configured tables
* [#7446](https://github.com/netbox-community/netbox/issues/7446) - Fix exception when viewing a large number of child IPs within a prefix
* [#7455](https://github.com/netbox-community/netbox/issues/7455) - Fix site/provider network validation for circuit termination API serializer
* [#7459](https://github.com/netbox-community/netbox/issues/7459) - Pre-populate location data when adding a device to a rack
* [#7460](https://github.com/netbox-community/netbox/issues/7460) - Fix filtering connections by site ID
---
## v3.0.5 (2021-10-04)
### Enhancements
@@ -8,7 +64,6 @@
* [#6423](https://github.com/netbox-community/netbox/issues/6423) - Cache rendered REST API specifications
* [#6708](https://github.com/netbox-community/netbox/issues/6708) - Add image attachment support for circuits, power panels
* [#7387](https://github.com/netbox-community/netbox/issues/7387) - Enable arbitrary ordering of custom scripts
* [#7427](https://github.com/netbox-community/netbox/issues/7427) - Don't select hidden rows when selecting all in a table
### Bug Fixes
@@ -23,6 +78,7 @@
* [#7412](https://github.com/netbox-community/netbox/issues/7412) - Fix exception in UI when adding child device to device bay
* [#7417](https://github.com/netbox-community/netbox/issues/7417) - Prevent exception when filtering objects list by invalid tag
* [#7425](https://github.com/netbox-community/netbox/issues/7425) - Housekeeping command should honor zero verbosity
* [#7427](https://github.com/netbox-community/netbox/issues/7427) - Don't select hidden rows when selecting all in a table
---

View File

@@ -65,7 +65,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'

View File

@@ -3,10 +3,10 @@ from rest_framework import serializers
from circuits.choices import CircuitStatusChoices
from circuits.models import *
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer
from dcim.api.serializers import CableTerminationSerializer
from netbox.api import ChoiceField
from netbox.api.serializers import (
BaseModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer
OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
)
from tenancy.api.nested_serializers import NestedTenantSerializer
from .nested_serializers import *
@@ -90,11 +90,11 @@ class CircuitSerializer(PrimaryModelSerializer):
]
class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer):
class CircuitTerminationSerializer(ValidatedModelSerializer, CableTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer(required=False)
provider_network = NestedProviderNetworkSerializer(required=False)
site = NestedSiteSerializer(required=False, allow_null=True)
provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True)
cable = NestedCableSerializer(read_only=True)
class Meta:

View File

@@ -136,14 +136,20 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
SIDE_A = CircuitTerminationSideChoices.SIDE_A
SIDE_Z = CircuitTerminationSideChoices.SIDE_Z
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
provider_networks = (
ProviderNetwork(provider=provider, name='Provider Network 1'),
ProviderNetwork(provider=provider, name='Provider Network 2'),
)
ProviderNetwork.objects.bulk_create(provider_networks)
circuits = (
Circuit(cid='Circuit 1', provider=provider, type=circuit_type),
@@ -153,10 +159,10 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
Circuit.objects.bulk_create(circuits)
circuit_terminations = (
CircuitTermination(circuit=circuits[0], site=sites[0], term_side=SIDE_A),
CircuitTermination(circuit=circuits[0], site=sites[1], term_side=SIDE_Z),
CircuitTermination(circuit=circuits[1], site=sites[0], term_side=SIDE_A),
CircuitTermination(circuit=circuits[1], site=sites[1], term_side=SIDE_Z),
CircuitTermination(circuit=circuits[0], term_side=SIDE_A, site=sites[0]),
CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, provider_network=provider_networks[0]),
CircuitTermination(circuit=circuits[1], term_side=SIDE_A, site=sites[1]),
CircuitTermination(circuit=circuits[1], term_side=SIDE_Z, provider_network=provider_networks[1]),
)
CircuitTermination.objects.bulk_create(circuit_terminations)
@@ -164,13 +170,13 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
{
'circuit': circuits[2].pk,
'term_side': SIDE_A,
'site': sites[1].pk,
'site': sites[0].pk,
'port_speed': 200000,
},
{
'circuit': circuits[2].pk,
'term_side': SIDE_Z,
'site': sites[1].pk,
'provider_network': provider_networks[0].pk,
'port_speed': 200000,
},
]

View File

@@ -2,7 +2,7 @@ import socket
from collections import OrderedDict
from django.conf import settings
from django.http import HttpResponseForbidden, HttpResponse
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404
from drf_yasg import openapi
from drf_yasg.openapi import Parameter
@@ -17,10 +17,10 @@ from dcim import filtersets
from dcim.models import *
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
from ipam.models import Prefix, VLAN
from netbox.api.views import ModelViewSet
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.views import ModelViewSet
from utilities.api import get_serializer_for_model
from utilities.utils import count_related, decode_dict
from virtualization.models import VirtualMachine
@@ -675,15 +675,25 @@ class ConnectedDeviceViewSet(ViewSet):
if not peer_device_name or not peer_interface_name:
raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.')
# Determine local interface from peer interface's connection
# Determine local endpoint from peer interface's connection
peer_device = get_object_or_404(
Device.objects.restrict(request.user, 'view'),
name=peer_device_name
)
peer_interface = get_object_or_404(
Interface.objects.all(),
device__name=peer_device_name,
Interface.objects.restrict(request.user, 'view'),
device=peer_device,
name=peer_interface_name
)
local_interface = peer_interface.connected_endpoint
endpoint = peer_interface.connected_endpoint
if local_interface is None:
return Response()
# If an Interface, return the parent device
if type(endpoint) is Interface:
device = get_object_or_404(
Device.objects.restrict(request.user, 'view'),
pk=endpoint.device_id
)
return Response(serializers.DeviceSerializer(device, context={'request': request}).data)
return Response(serializers.DeviceSerializer(local_interface.device, context={'request': request}).data)
# Connected endpoint is none or not an Interface
raise Http404

View File

@@ -192,6 +192,7 @@ class ConsolePortTypeChoices(ChoiceSet):
TYPE_USB_MINI_B = 'usb-mini-b'
TYPE_USB_MICRO_A = 'usb-micro-a'
TYPE_USB_MICRO_B = 'usb-micro-b'
TYPE_USB_MICRO_AB = 'usb-micro-ab'
TYPE_OTHER = 'other'
CHOICES = (
@@ -210,6 +211,7 @@ class ConsolePortTypeChoices(ChoiceSet):
(TYPE_USB_MINI_B, 'USB Mini B'),
(TYPE_USB_MICRO_A, 'USB Micro A'),
(TYPE_USB_MICRO_B, 'USB Micro B'),
(TYPE_USB_MICRO_AB, 'USB Micro AB'),
)),
('Other', (
(TYPE_OTHER, 'Other'),
@@ -337,6 +339,7 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_USB_MINI_B = 'usb-mini-b'
TYPE_USB_MICRO_A = 'usb-micro-a'
TYPE_USB_MICRO_B = 'usb-micro-b'
TYPE_USB_MICRO_AB = 'usb-micro-ab'
TYPE_USB_3_B = 'usb-3-b'
TYPE_USB_3_MICROB = 'usb-3-micro-b'
# Direct current (DC)
@@ -444,6 +447,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_USB_MINI_B, 'USB Mini B'),
(TYPE_USB_MICRO_A, 'USB Micro A'),
(TYPE_USB_MICRO_B, 'USB Micro B'),
(TYPE_USB_MICRO_AB, 'USB Micro AB'),
(TYPE_USB_3_B, 'USB 3.0 Type B'),
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
)),
@@ -681,6 +685,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

View File

@@ -10,14 +10,14 @@ from tenancy.filtersets import TenancyFilterSet
from tenancy.models import Tenant
from utilities.choices import ColorChoices
from utilities.filters import (
MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter,
TreeNodeMultipleChoiceFilter,
)
from virtualization.models import Cluster
from .choices import *
from .constants import *
from .models import *
__all__ = (
'CableFilterSet',
'CableTerminationFilterSet',
@@ -480,12 +480,21 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
class DeviceTypeComponentFilterSet(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
devicetype_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceType.objects.all(),
field_name='device_type_id',
label='Device type (ID)',
)
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(name__icontains=value)
class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
@@ -1184,6 +1193,10 @@ class CableFilterSet(PrimaryModelFilterSet):
method='search',
label='Search',
)
termination_a_type = ContentTypeFilter()
termination_a_id = MultiValueNumberFilter()
termination_b_type = ContentTypeFilter()
termination_b_id = MultiValueNumberFilter()
type = django_filters.MultipleChoiceFilter(
choices=CableTypeChoices
)
@@ -1228,7 +1241,7 @@ class CableFilterSet(PrimaryModelFilterSet):
class Meta:
model = Cable
fields = ['id', 'label', 'length', 'length_unit']
fields = ['id', 'label', 'length', 'length_unit', 'termination_a_id', 'termination_b_id']
def search(self, queryset, name, value):
if not value.strip():
@@ -1243,73 +1256,6 @@ class CableFilterSet(PrimaryModelFilterSet):
return queryset
class ConnectionFilterSet(BaseFilterSet):
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(**{f'{name}__in': value})
class ConsoleConnectionFilterSet(ConnectionFilterSet):
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device',
field_name='device__name'
)
class Meta:
model = ConsolePort
fields = ['name']
class PowerConnectionFilterSet(ConnectionFilterSet):
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device',
field_name='device__name'
)
class Meta:
model = PowerPort
fields = ['name']
class InterfaceConnectionFilterSet(ConnectionFilterSet):
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device',
field_name='device__name'
)
class Meta:
model = Interface
fields = []
class PowerPanelFilterSet(PrimaryModelFilterSet):
q = django_filters.CharFilter(
method='search',
@@ -1441,3 +1387,52 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE
Q(comments__icontains=value)
)
return queryset.filter(qs_filter)
#
# Connection filter sets
#
class ConnectionFilterSet(BaseFilterSet):
site_id = MultiValueNumberFilter(
method='filter_connections',
field_name='device__site_id'
)
site = MultiValueCharFilter(
method='filter_connections',
field_name='device__site__slug'
)
device_id = MultiValueNumberFilter(
method='filter_connections',
field_name='device_id'
)
device = MultiValueCharFilter(
method='filter_connections',
field_name='device__name'
)
def filter_connections(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(**{f'{name}__in': value})
class ConsoleConnectionFilterSet(ConnectionFilterSet):
class Meta:
model = ConsolePort
fields = ['name']
class PowerConnectionFilterSet(ConnectionFilterSet):
class Meta:
model = PowerPort
fields = ['name']
class InterfaceConnectionFilterSet(ConnectionFilterSet):
class Meta:
model = Interface
fields = []

View File

@@ -957,9 +957,14 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
model = Interface
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address'],
['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address'],
['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,

View File

@@ -112,6 +112,9 @@ class RackElevationSVG:
)
image.fit(scale='slice')
link.add(image)
link.add(drawing.text(str(name), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
def _draw_device_rear(self, drawing, device, start, end, text):
rect = drawing.rect(start, end, class_="slot blocked")
@@ -129,17 +132,24 @@ class RackElevationSVG:
)
image.fit(scale='slice')
drawing.add(image)
drawing.add(drawing.text(str(device), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
drawing.add(drawing.text(str(device), insert=text, fill='white', class_='device-image-label'))
@staticmethod
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
link_url = '{}?{}'.format(
reverse('dcim:device_add'),
urlencode({
'site': rack.site.pk,
'location': rack.location.pk if rack.location else '',
'rack': rack.pk,
'face': face_id,
'position': id_
})
)
link = drawing.add(
drawing.a(
href='{}?{}'.format(
reverse('dcim:device_add'),
urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
),
target='_top'
)
drawing.a(href=link_url, target='_top')
)
if reservation:
link.set_desc('{}{} · {}'.format(

View File

@@ -1,4 +1,5 @@
from django.contrib.auth.models import User
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
@@ -1490,40 +1491,35 @@ class ConnectedDeviceTest(APITestCase):
super().setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.devicetype1 = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
self.devicetype2 = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 2', slug='test-device-type-2'
)
self.devicerole1 = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.devicerole2 = DeviceRole.objects.create(
name='Test Device Role 2', slug='test-device-role-2', color='00ff00'
)
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
self.device1 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice1', site=self.site1
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
)
self.device2 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice2', site=self.site1
device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
)
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected
cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
cable.save()
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_connected_device(self):
url = reverse('dcim-api:connected-device-list')
response = self.client.get(url + '?peer_device=TestDevice2&peer_interface=eth0', **self.header)
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface1.name}'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['name'], self.device1.name)
self.assertEqual(response.data['name'], self.device2.name)
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface3.name}'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):

View File

@@ -2851,6 +2851,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Interface.objects.bulk_create(interfaces)
console_port = ConsolePort.objects.create(device=devices[0], name='Console Port 1')
console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
# Cables
Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
@@ -2858,6 +2861,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
def test_label(self):
params = {'label': ['Cable 1', 'Cable 2']}
@@ -2877,7 +2881,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_status(self):
params = {'status': [CableStatusChoices.STATUS_CONNECTED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'status': [CableStatusChoices.STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
@@ -2888,30 +2892,44 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_site(self):
site = Site.objects.all()[:2]
params = {'site_id': [site[0].pk, site[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'site': [site[0].slug, site[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_tenant(self):
tenant = Tenant.objects.all()[:2]
params = {'tenant_id': [tenant[0].pk, tenant[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
params = {'tenant': [tenant[0].slug, tenant[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
def test_termination_types(self):
params = {'termination_a_type': 'dcim.consoleport'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'termination_b_type': 'dcim.consoleserverport'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_termination_ids(self):
interface_ids = Cable.objects.values_list('termination_a_id', flat=True)[:3]
params = {
'termination_a_type': 'dcim.interface',
'termination_a_id': list(interface_ids),
}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):

View File

@@ -1229,6 +1229,7 @@ class PlatformView(generic.ObjectView):
return {
'devices_table': devices_table,
'virtualmachine_count': VirtualMachine.objects.filter(platform=instance).count()
}

View File

@@ -1,3 +1,4 @@
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from drf_yasg.utils import swagger_serializer_method
@@ -30,6 +31,7 @@ __all__ = (
'ExportTemplateSerializer',
'ImageAttachmentSerializer',
'JobResultSerializer',
'JournalEntrySerializer',
'ObjectChangeSerializer',
'ReportDetailSerializer',
'ReportSerializer',
@@ -192,6 +194,12 @@ class JournalEntrySerializer(ValidatedModelSerializer):
queryset=ContentType.objects.all()
)
assigned_object = serializers.SerializerMethodField(read_only=True)
created_by = serializers.PrimaryKeyRelatedField(
allow_null=True,
queryset=User.objects.all(),
required=False,
default=serializers.CurrentUserDefault()
)
kind = ChoiceField(
choices=JournalEntryKindChoices,
required=False

View File

@@ -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:

View File

@@ -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',

View File

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

View File

@@ -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,
}

View File

@@ -283,13 +283,10 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
if '_addanother' in request.POST:
redirect_url = request.path
return_url = request.GET.get('return_url')
if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
redirect_url = f'{redirect_url}?return_url={return_url}'
# If the object has clone_fields, pre-populate a new instance of the form
if hasattr(obj, 'clone_fields'):
redirect_url += f"{'&' if return_url else '?'}{prepare_cloned_fields(obj)}"
redirect_url += f"?{prepare_cloned_fields(obj)}"
return redirect(redirect_url)
@@ -880,6 +877,8 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
initial_data['device'] = request.GET.get('device')
elif 'device_type' in request.GET:
initial_data['device_type'] = request.GET.get('device_type')
elif 'virtual_machine' in request.GET:
initial_data['virtual_machine'] = request.GET.get('virtual_machine')
form = self.form(model, initial=initial_data)
restrict_form_fields(form, request.user)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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<string, string>([
// 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<HTMLTableCellElement>('td.device');
const cellInterface = row.querySelector<HTMLTableCellElement>('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<HTMLTableCellElement>('td.device');
const interfaceCell = row.querySelector<HTMLTableCellElement>('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');

View File

@@ -1,92 +1,90 @@
import { rackImagesState } from './stores';
import { rackImagesState, RackViewSelection } from './stores';
import { getElements } from './util';
import type { StateManager } from './state';
type RackToggleState = { hidden: boolean };
export type RackViewState = { view: RackViewSelection };
/**
* Toggle the Rack Image button to reflect the current state. If the current state is hidden and
* the images are therefore hidden, the button should say "Show Images". Likewise, if the current
* state is *not* hidden, and therefore the images are shown, the button should say "Hide Images".
*
* @param hidden Current State - `true` if images are hidden, `false` otherwise.
* @param button Button element.
* Show or hide images and labels to build the desired rack view.
*/
function toggleRackImagesButton(hidden: boolean, button: HTMLButtonElement): void {
const text = hidden ? 'Show Images' : 'Hide Images';
const selected = hidden ? '' : 'selected';
button.setAttribute('selected', selected);
button.innerHTML = `<i class="mdi mdi-file-image-outline"></i>&nbsp;${text}`;
}
/**
* Show all rack images.
*/
function showRackImages(): void {
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
const images = elevation.contentDocument?.querySelectorAll('image.device-image') ?? [];
for (const image of images) {
image.classList.remove('hidden');
}
}
}
/**
* Hide all rack images.
*/
function hideRackImages(): void {
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
const images = elevation.contentDocument?.querySelectorAll('image.device-image') ?? [];
for (const image of images) {
image.classList.add('hidden');
}
}
}
/**
* Toggle the visibility of device images and update the toggle button style.
*/
function handleRackImageToggle(
target: HTMLButtonElement,
state: StateManager<RackToggleState>,
function setRackView(
view: RackViewSelection,
elevation: HTMLObjectElement,
): void {
const initiallyHidden = state.get('hidden');
state.set('hidden', !initiallyHidden);
const hidden = state.get('hidden');
if (hidden) {
hideRackImages();
} else {
showRackImages();
switch(view) {
case 'images-and-labels': {
showRackElements('image.device-image', elevation);
showRackElements('text.device-image-label', elevation);
break;
}
case 'images-only': {
showRackElements('image.device-image', elevation);
hideRackElements('text.device-image-label', elevation);
break;
}
case 'labels-only': {
hideRackElements('image.device-image', elevation);
hideRackElements('text.device-image-label', elevation);
break;
}
}
}
function showRackElements(
selector: string,
elevation: HTMLObjectElement,
): void {
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
for (const element of elements) {
element.classList.remove('hidden');
}
}
function hideRackElements(
selector: string,
elevation: HTMLObjectElement,
): void {
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
for (const element of elements) {
element.classList.add('hidden');
}
toggleRackImagesButton(hidden, target);
}
/**
* Add onClick callback for toggling rack elevation images. Synchronize the image toggle button
* text and display state of images with the local state.
* Change the visibility of all racks in response to selection.
*/
function handleRackViewSelect(
newView: RackViewSelection,
state: StateManager<RackViewState>,
): void {
state.set('view', newView);
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
setRackView(newView, elevation);
}
}
/**
* Add change callback for selecting rack elevation images, and set
* initial state of select and the images themselves
*/
export function initRackElevation(): void {
const initiallyHidden = rackImagesState.get('hidden');
for (const button of getElements<HTMLButtonElement>('button.toggle-images')) {
toggleRackImagesButton(initiallyHidden, button);
const initialView = rackImagesState.get('view');
button.addEventListener(
'click',
for (const control of getElements<HTMLSelectElement>('select.rack-view')) {
control.selectedIndex = [...control.options].findIndex(o => o.value == initialView);
control.addEventListener(
'change',
event => {
handleRackImageToggle(event.currentTarget as HTMLButtonElement, rackImagesState);
handleRackViewSelect((event.currentTarget as any).value as RackViewSelection, rackImagesState);
},
false,
);
}
for (const element of getElements<HTMLObjectElement>('.rack_elevation')) {
element.addEventListener('load', () => {
if (initiallyHidden) {
hideRackImages();
} else if (!initiallyHidden) {
showRackImages();
}
setRackView(initialView, element);
});
}
}

View File

@@ -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');
}
}

View File

@@ -1,6 +1,8 @@
import { createState } from '../state';
export const rackImagesState = createState<{ hidden: boolean }>(
{ hidden: false },
export type RackViewSelection = 'images-and-labels' | 'images-only' | 'labels-only';
export const rackImagesState = createState<{ view: RackViewSelection }>(
{ view: 'images-and-labels' },
{ persist: true },
);

View File

@@ -73,16 +73,6 @@
color: color-contrast($value);
}
}
// Use proper foreground color in the alert body. Note: this is applied to p, & small because
// we *don't* want to override the h1-h6 colors for alerts, since those are set to a color
// similar to the alert color.
.alert.alert-#{$color} {
p,
small {
color: color-contrast($value);
}
}
}
// Ensure progress bars (utilization graph) in tables aren't too narrow to display the percentage.
@@ -200,16 +190,27 @@ div#advanced-search-content div.card div.card-body div.col:not(:last-child) {
}
table {
a {
text-decoration: none;
&:hover {
text-decoration: underline;
td {
a {
text-decoration: none;
&:hover {
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;
}
}
&.table > :not(caption) > * > * {
padding-right: $table-cell-padding-x-sm !important;
padding-left: $table-cell-padding-x-sm !important;
th {
a,
a:hover {
color: $body-color;
text-decoration: none;
}
}
td,
th {
font-size: $font-size-sm;
@@ -234,6 +235,11 @@ table {
}
}
&.table > :not(caption) > * > * {
padding-right: $table-cell-padding-x-sm !important;
padding-left: $table-cell-padding-x-sm !important;
}
&.object-list {
th {
font-size: $font-size-xs;

View File

@@ -70,6 +70,7 @@ $spacing-s: $input-padding-x;
span.arrow-down,
span.arrow-up {
border-color: currentColor;
color: $text-muted;
}
}
// Don't show the depth indicator outside of the menu.

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -7,6 +7,7 @@ $input-border-color: $gray-200;
$theme-colors: map-merge(
$theme-colors,
(
'primary': #337ab7,
'red': $red-500,
'yellow': $yellow-500,
'green': $green-500,

View File

@@ -23,7 +23,7 @@
--nbx-color-mode-toggle-color: #{$primary};
--nbx-sidenav-link-color: #{$gray-800};
--nbx-sidenav-pin-color: #{$orange};
--nbx-sidenav-parent-color: #{$gray-900};
--nbx-sidenav-parent-color: #{$gray-800};
--nbx-sidenav-group-color: #{$gray-800};
&[data-netbox-color-mode='dark'] {
@@ -49,7 +49,7 @@
--nbx-color-mode-toggle-color: #{$yellow-300};
--nbx-sidenav-link-color: #{$gray-200};
--nbx-sidenav-pin-color: #{$yellow};
--nbx-sidenav-parent-color: #{$gray-100};
--nbx-sidenav-parent-color: #{$gray-200};
--nbx-sidenav-group-color: #{$gray-600};
}
}

View File

@@ -5,7 +5,7 @@
{% block title %}{{ obj.circuit.provider }} {{ obj.circuit }} - Side {{ form.term_side.value }}{% endblock %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Circuit Termination</h5>
</div>
@@ -53,9 +53,8 @@
</div>
{% endwith %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Termination Details</h5>
</div>

View File

@@ -17,9 +17,7 @@
<div class="row my-3">
<div class="col col-md-5">
<div class="card h-100">
<h5 class="card-header">
A Side
</h5>
<h5 class="card-header offset-sm-3">A Side</h5>
<div class="card-body">
{% if termination_a.device %}
{# Device component #}
@@ -100,9 +98,7 @@
</div>
<div class="col col-md-5">
<div class="card h-100">
<h5 class="card-header">
B Side
</h5>
<h5 class="card-header offset-sm-3">B Side</h5>
<div class="card-body">
{% if tabs %}
<ul class="nav nav-tabs">
@@ -154,7 +150,7 @@
<div class="row my-3 justify-content-center">
<div class="col col-md-8">
<div class="card">
<h5 class="card-header">Cable</h5>
<h5 class="card-header offset-sm-3">Cable</h5>
<div class="card-body">
{% include 'dcim/inc/cable_form.html' %}
</div>

View File

@@ -4,111 +4,104 @@
{% block form %}
{% render_errors form %}
<div class="field-group my-4">
<div class="row mb-2">
<h5 class="offset-sm-3">Device</h5>
</div>
{% render_field form.name %}
{% render_field form.device_role %}
{% render_field form.tags %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Device</h5>
</div>
{% render_field form.name %}
{% render_field form.device_role %}
{% render_field form.tags %}
</div>
<hr />
<div class="field-group my-4">
<div class="row mb-2">
<h5 class="offset-sm-3">Hardware</h5>
</div>
{% render_field form.manufacturer %}
{% render_field form.device_type %}
{% render_field form.serial %}
{% render_field form.asset_tag %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Hardware</h5>
</div>
{% render_field form.manufacturer %}
{% render_field form.device_type %}
{% render_field form.serial %}
{% render_field form.asset_tag %}
</div>
<hr />
<div class="field-group my-4">
<div class="row mb-2">
<h5 class="offset-sm-3">Location</h5>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Location</h5>
</div>
{% render_field form.region %}
{% render_field form.site_group %}
{% render_field form.site %}
{% render_field form.location %}
{% render_field form.rack %}
{% if obj.device_type.is_child_device and obj.parent_bay %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Parent Device</label>
<div class="col">
<input class="form-control" value="{{ obj.parent_bay.device }}" disabled />
</div>
</div>
{% render_field form.region %}
{% render_field form.site_group %}
{% render_field form.site %}
{% render_field form.location %}
{% render_field form.rack %}
{% if obj.device_type.is_child_device and obj.parent_bay %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Parent Device</label>
<div class="col">
<input class="form-control" value="{{ obj.parent_bay.device }}" disabled />
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Parent Bay</label>
<div class="col">
<div class="input-group">
<input class="form-control" value="{{ obj.parent_bay.name }}" disabled />
<a href="{% url 'dcim:devicebay_depopulate' pk=obj.parent_bay.pk %}" title="Regenerate Slug" class="btn btn-danger d-inline-flex align-items-center">
<i class="mdi mdi-close-thick"></i>&nbsp;Remove
</a>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Parent Bay</label>
<div class="col">
<div class="input-group">
<input class="form-control" value="{{ obj.parent_bay.name }}" disabled />
<a href="{% url 'dcim:devicebay_depopulate' pk=obj.parent_bay.pk %}" title="Regenerate Slug" class="btn btn-danger d-inline-flex align-items-center">
<i class="mdi mdi-close-thick"></i>&nbsp;Remove
</a>
</div>
</div>
</div>
{% else %}
{% render_field form.face %}
{% render_field form.position %}
{% endif %}
</div>
{% else %}
{% render_field form.face %}
{% render_field form.position %}
{% endif %}
</div>
<hr />
<div class="field-group my-4">
<div class="row mb-2">
<h5 class="offset-sm-3">Management</h5>
</div>
{% render_field form.status %}
{% render_field form.platform %}
{% if obj.pk %}
{% render_field form.primary_ip4 %}
{% render_field form.primary_ip6 %}
{% endif %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Management</h5>
</div>
{% render_field form.status %}
{% render_field form.platform %}
{% if obj.pk %}
{% render_field form.primary_ip4 %}
{% render_field form.primary_ip6 %}
{% endif %}
</div>
<hr />
<div class="field-group my-4">
<div class="row mb-2">
<h5 class="offset-sm-3">Virtualization</h5>
</div>
{% render_field form.cluster_group %}
{% render_field form.cluster %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Virtualization</h5>
</div>
{% render_field form.cluster_group %}
{% render_field form.cluster %}
</div>
<hr />
<div class="field-group my-4">
<div class="row mb-2">
<h5 class="offset-sm-3">Tenancy</h5>
</div>
{% render_field form.tenant_group %}
{% render_field form.tenant %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Tenancy</h5>
</div>
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
<hr />
{% if form.custom_fields %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>
{% render_custom_fields form %}
</div>
<hr />
{% endif %}
<div class="field-group my-4">
<h5 class="text-center">Local Config Context Data</h5>
{% render_field form.local_context_data %}
<div class="field-group my-5">
<h5 class="text-center">Local Config Context Data</h5>
{% render_field form.local_context_data %}
</div>
<hr />
<div class="field-group my-4">
{% render_field form.comments label='Comments' %}
<div class="field-group mb-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div>
{% endblock %}

View File

@@ -137,7 +137,7 @@
</div>
<div class="row my-3">
<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">
<button class="nav-link active" data-bs-target="#interfaces" role="tab" data-bs-toggle="tab">
Interfaces {% badge interface_table.rows|length %}

View File

@@ -16,7 +16,6 @@
</div>
{% render_field form.tags %}
{% if form.custom_fields %}
<hr />
<div class="field-group">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>

View File

@@ -2,7 +2,7 @@
{% load form_helpers %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Interface</h5>
</div>
@@ -27,9 +27,8 @@
{% render_field form.mgmt_only %}
{% render_field form.mark_connected %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">802.1Q Switching</h5>
</div>
@@ -40,8 +39,7 @@
</div>
{% if form.custom_fields %}
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>

View File

@@ -46,6 +46,12 @@
<a href="{% url 'dcim:device_list' %}?platform_id={{ object.pk }}">{{ devices_table.rows|length }}</a>
</td>
</tr>
<tr>
<th scope="row">Virtual Machines</th>
<td>
<a href="{% url 'virtualization:virtualmachine_list' %}?platform_id={{ object.pk }}">{{ virtualmachine_count }}</a>
</td>
</tr>
</table>
</div>
</div>

View File

@@ -18,10 +18,6 @@
{% endblock %}
{% block extra_controls %}
<button class="btn btn-sm btn-outline-primary toggle-images" selected="selected">
<i class="mdi mdi-file-image-outline"></i>
Hide Images
</button>
<a {% if prev_rack %}href="{% url 'dcim:rack' pk=prev_rack.pk %}{% endif %}" class="btn btn-sm btn-primary{% if not prev_rack %} disabled{% endif %}">
<i class="mdi mdi-chevron-left" aria-hidden="true"></i> Previous
</a>
@@ -271,6 +267,13 @@
{% plugin_left_page object %}
</div>
<div class="col col-12 col-xl-7">
<div class="text-end mb-4">
<select class="btn btn-sm btn-outline-dark rack-view">
<option value="images-and-labels" selected="selected">Images and Labels</option>
<option value="images-only">Images only</option>
<option value="labels-only">Labels only</option>
</select>
</div>
<div class="row" style="margin-bottom: 20px">
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">

View File

@@ -2,7 +2,7 @@
{% load form_helpers %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Rack</h5>
</div>
@@ -15,9 +15,8 @@
{% render_field form.role %}
{% render_field form.tags %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Inventory Control</h5>
</div>
@@ -25,18 +24,16 @@
{% render_field form.serial %}
{% render_field form.asset_tag %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Tenancy</h5>
</div>
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Dimensions</h5>
</div>
@@ -45,34 +42,33 @@
{% render_field form.u_height %}
<div class="row mb-3">
<label class="col col-md-3 col-form-label text-lg-end">Outer Dimensions</label>
<div class="col col-md-3">
<div class="col col-md-3 mb-1">
{{ form.outer_width }}
<div class="form-text">Width</div>
</div>
<div class="col col-md-3">
<div class="col col-md-3 mb-1">
{{ form.outer_depth }}
<div class="form-text">Depth</div>
</div>
<div class="col col-md-3">
<div class="col col-md-3 mb-1">
{{ form.outer_unit }}
<div class="form-text">Unit</div>
</div>
</div>
{% render_field form.desc_units %}
</div>
<hr />
{% if form.custom_fields %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>
{% render_custom_fields form %}
</div>
<hr />
{% endif %}
<div class="field-group my-4">
{% render_field form.comments label='Comments' %}
<div class="field-group my-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div>
{% endblock %}

View File

@@ -7,9 +7,13 @@
{% block controls %}
<div class="controls">
<div class="control-group">
<button class="btn btn-sm btn-outline-dark toggle-images" selected="selected">
<span class="mdi mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></span> Show Images
</button>
<div class="btn-group btn-group-sm" role="group">
<select class="btn btn-sm btn-outline-secondary rack-view">
<option value="images-and-labels" selected="selected">Images and Labels</option>
<option value="images-only">Images only</option>
<option value="labels-only">Labels only</option>
</select>
</div>
<div class="btn-group btn-group-sm" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-outline-secondary{% if rack_face == 'front' %} active{% endif %}">Front</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-outline-secondary{% if rack_face == 'rear' %} active{% endif %}">Rear</a>

View File

@@ -2,7 +2,7 @@
{% load form_helpers %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Virtual Chassis</h5>
</div>
@@ -10,9 +10,8 @@
{% render_field form.domain %}
{% render_field form.tags %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Member Devices</h5>
</div>
@@ -25,8 +24,7 @@
</div>
{% if form.custom_fields %}
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>

View File

@@ -11,7 +11,7 @@
{% csrf_token %}
{{ pk_form.pk }}
{{ formset.management_form }}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Virtual Chassis</h5>
</div>
@@ -20,16 +20,14 @@
{% render_field vc_form.master %}
{% render_field vc_form.tags %}
</div>
<hr />
{% if vc_form.custom_fields %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>
{% render_custom_fields vc_form %}
</div>
<hr />
{% endif %}
<div class="field-group mb-5">

View File

@@ -130,12 +130,12 @@
</h5>
<div class="card-body">
{% if object.postchange_data %}
<pre class="change-data">{% for k, v in object.postchange_data.items %}{% spaceless %}
<span{% if k in diff_added %} class="added"{% endif %}>{{ k }}: {{ v|render_json }}</span>
{% endspaceless %}{% endfor %}
</pre>
<pre class="change-data">{% for k, v in object.postchange_data.items %}{% spaceless %}
<span{% if k in diff_added %} class="added"{% endif %}>{{ k }}: {{ v|render_json }}</span>
{% endspaceless %}{% endfor %}
</pre>
{% else %}
<span class="text-muted">None</span>
<span class="text-muted">None</span>
{% endif %}
</div>
</div>

View File

@@ -3,6 +3,10 @@
{% block title %}{{ report.name }}{% endblock %}
{% block object_identifier %}
{{ report.full_name }}
{% endblock %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}">Reports</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</a></li>

View File

@@ -5,6 +5,10 @@
{% block title %}{{ script }}{% endblock %}
{% block object_identifier %}
{{ script.full_name }}
{% endblock %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">Scripts</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ module }}">{{ module|bettertitle }}</a></li>

View File

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

View File

@@ -6,14 +6,25 @@
{% load plugins %}
{% block header %}
{# Breadcrumbs #}
<nav class="breadcrumb-container px-3" aria-label="breadcrumb">
<ol class="breadcrumb">
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url object|viewname:'list' %}">{{ object|meta:'verbose_name_plural'|bettertitle }}</a></li>
{% endblock %}
</ol>
</nav>
<div class="d-flex justify-content-between align-items-center">
{# Breadcrumbs #}
<nav class="breadcrumb-container px-3" aria-label="breadcrumb">
<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">
{% block object_identifier %}
{{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }}
{% if object.slug %}({{ object.slug }}){% endif %}
{% endblock object_identifier %}
</code>
</div>
</div>
{{ block.super }}
{% endblock %}

View File

@@ -6,18 +6,6 @@
{% if obj.pk %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}
{% endblock title %}
{% block controls %}
{% if obj and settings.DOCS_ROOT %}
<div class="controls">
<div class="control-group">
<a href="{{ obj|get_docs_url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="View model documentation">
<i class="mdi mdi-help-circle"></i> Help
</a>
</div>
</div>
{% endif %}
{% endblock controls %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
@@ -31,6 +19,16 @@
{% block content-wrapper %}
<div class="tab-content">
<div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="object-list-tab">
{# Link to model documentation #}
{% if obj and settings.DOCS_ROOT %}
<div class="float-end">
<a href="{{ obj|get_docs_url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="View model documentation">
<i class="mdi mdi-help-circle"></i> Help
</a>
</div>
{% endif %}
<form action="" method="post" enctype="multipart/form-data" class="form-object-edit">
{% csrf_token %}
{% for field in form.hidden_fields %}
@@ -42,7 +40,7 @@
{# Render grouped fields according to Form #}
{% for group, fields in form.Meta.fieldsets %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{{ group }}</h5>
</div>
@@ -50,14 +48,10 @@
{% render_field form|getfield:name %}
{% endfor %}
</div>
{% if not forloop.last %}
<hr />
{% endif %}
{% endfor %}
{% if form.custom_fields %}
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>
@@ -66,15 +60,15 @@
{% endif %}
{% if form.comments %}
<hr />
<div class="field-group my-4">
{% render_field form.comments label='Comments' %}
<div class="field-group my-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div>
{% endif %}
{% else %}
{# Render all fields in a single group #}
<div class="field-group my-4">
<div class="field-group my-5">
{% block form_fields %}{% render_form form %}{% endblock %}
</div>
{% endif %}

View File

@@ -4,6 +4,8 @@
{% load render_table from django_tables2 %}
{% load static %}
{% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bettertitle }}{% endblock %}
{% block controls %}
<div class="controls">
<div class="control-group">
@@ -26,7 +28,7 @@
{% block tab_items %}
<li class="nav-item" role="presentation">
<button class="nav-link active" id="object-list-tab" data-bs-toggle="tab" data-bs-target="#object-list" type="button" role="tab" aria-controls="edit-form" aria-selected="true">
{% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bettertitle }}{% endblock %}
Records
{% badge table.page.paginator.count %}
</button>
</li>

View File

@@ -30,7 +30,7 @@
{% for section, items, icon in stats %}
<div class="col col-sm-12 col-lg-6 col-xl-4 my-2 masonry-item">
<div class="card">
<h6 class="card-header text-primary text-center">
<h6 class="card-header text-center">
<i class="mdi mdi-{{ icon }}"></i>
<span class="ms-1">{{ section }}</span>
</h6>
@@ -67,7 +67,7 @@
<div class="row my-4 flex-grow-1 changelog-container">
<div class="col">
<div class="card">
<h6 class="card-header text-primary text-center">
<h6 class="card-header text-center">
<i class="mdi mdi-clipboard-clock"></i>
<span class="ms-1">Change Log</span>
</h6>

View File

@@ -1,41 +1,43 @@
{% 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 %}
<thead>
<tr>
{% for column in table.columns %}
{% if column.orderable %}
<th {{ column.attrs.th.as_html }}><a href="{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}">{{ column.header }}</a></th>
{% else %}
<th {{ column.attrs.th.as_html }}>{{ column.header }}</th>
{% endif %}
{% endfor %}
</tr>
</thead>
<thead>
<tr>
{% for column in table.columns %}
{% if column.orderable %}
<th {{ column.attrs.th.as_html }}><a href="{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}">{{ column.header }}</a></th>
{% else %}
<th {{ column.attrs.th.as_html }}>{{ column.header }}</th>
{% endif %}
{% endfor %}
</tr>
</thead>
{% endif %}
<tbody>
{% for row in table.page.object_list|default:table.rows %}
<tr {{ row.attrs.as_html }}>
{% for column, cell in row.items %}
<td {{ column.attrs.td.as_html }}>{{ cell }}</td>
{% endfor %}
</tr>
{% empty %}
{% if table.empty_text %}
<tr>
<td colspan="{{ table.columns|length }}" class="text-center text-muted">&mdash; {{ table.empty_text }} &mdash;</td>
</tr>
{% endif %}
{% endfor %}
{% for row in table.page.object_list|default:table.rows %}
<tr {{ row.attrs.as_html }}>
{% for column, cell in row.items %}
<td {{ column.attrs.td.as_html }}>{{ cell }}</td>
{% endfor %}
</tr>
{% empty %}
{% if table.empty_text %}
<tr>
<td colspan="{{ table.columns|length }}" class="text-center text-muted">&mdash; {{ table.empty_text }} &mdash;</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
{% if table.has_footer %}
<tfoot>
<tr>
{% for column in table.columns %}
<td>{{ column.footer }}</td>
{% endfor %}
</tr>
</tfoot>
<tfoot>
<tr>
{% for column in table.columns %}
<td>{{ column.footer }}</td>
{% endfor %}
</tr>
</tfoot>
{% endif %}
</table>
</table>
</div>

View File

@@ -4,8 +4,8 @@
<input
type="text"
class="form-control object-filter"
placeholder="Filter"
title="Filter text (regular expressions supported)"
placeholder="Quick find"
title="Find in the results below (regular expressions supported)"
/>
</div>
</div>

View File

@@ -9,7 +9,7 @@
{% endblock %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">IP Addresses</h5>
</div>
@@ -20,9 +20,8 @@
{% render_field model_form.description %}
{% render_field model_form.tags %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Tenancy</h5>
</div>
@@ -30,8 +29,7 @@
{% render_field model_form.tenant %}
</div>
{% if model_form.custom_fields %}
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>

View File

@@ -8,7 +8,7 @@
{% endblock tabs %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">IP Address</h5>
</div>
@@ -20,18 +20,16 @@
{% render_field form.description %}
{% render_field form.tags %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Tenancy</h5>
</div>
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Interface Assignment</h5>
</div>
@@ -81,9 +79,8 @@
</div>
{% endwith %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">NAT IP (Inside)</h5>
</div>
@@ -152,8 +149,7 @@
</div>
{% if form.custom_fields %}
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>

View File

@@ -2,7 +2,7 @@
{% load form_helpers %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Service</h5>
</div>
@@ -43,7 +43,6 @@
</div>
{% if form.custom_fields %}
<hr />
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>

View File

@@ -4,7 +4,7 @@
{% load helpers %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">VLAN</h5>
</div>
@@ -15,18 +15,16 @@
{% render_field form.description %}
{% render_field form.tags %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Tenancy</h5>
</div>
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Assignment</h5>
</div>
@@ -58,8 +56,7 @@
</div>
{% if form.custom_fields %}
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>

View File

@@ -37,7 +37,7 @@
</td>
</tr>
<tr>
<th scope="row">Sites</th>
<th scope="row">Tenants</th>
<td>
<a href="{% url 'tenancy:tenant_list' %}?group_id={{ object.pk }}">{{ tenants_table.rows|length }}</a>
</td>

View File

@@ -13,7 +13,7 @@
<button type="submit" name="_rename" formaction="{% url 'virtualization:vminterface_bulk_rename' %}?return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-warning btn-sm">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'virtualization:vminterface_bulk_edit' %}?virtualmachine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-warning btn-sm">
<button type="submit" name="_edit" formaction="{% url 'virtualization:vminterface_bulk_edit' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-warning btn-sm">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
</button>
{% endif %}

View File

@@ -2,7 +2,7 @@
{% load form_helpers %}
{% block form %}
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Interface</h5>
</div>
@@ -22,9 +22,8 @@
{% render_field form.description %}
{% render_field form.tags %}
</div>
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">802.1Q Switching</h5>
</div>
@@ -35,8 +34,7 @@
</div>
{% if form.custom_fields %}
<hr />
<div class="field-group my-4">
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>

View File

@@ -57,14 +57,14 @@ class BaseTable(tables.Table):
if user is not None and not isinstance(user, AnonymousUser):
selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
if selected_columns:
pk = self.base_columns.pop('pk', None)
actions = self.base_columns.pop('actions', None)
# Show only persistent or selected columns
for name, column in self.columns.items():
if name in selected_columns:
if name in ['pk', 'actions', *selected_columns]:
self.columns.show(name)
else:
self.columns.hide(name)
# Rearrange the sequence to list selected columns first, followed by all remaining columns
# TODO: There's probably a more clever way to accomplish this
self.sequence = [
@@ -72,12 +72,14 @@ class BaseTable(tables.Table):
*[c for c in self.columns.names() if c not in selected_columns]
]
# Always include PK and actions column, if defined on the table
if pk:
self.base_columns['pk'] = pk
# PK column should always come first
if 'pk' in self.sequence:
self.sequence.remove('pk')
self.sequence.insert(0, 'pk')
if actions:
self.base_columns['actions'] = actions
# Actions column should always come last
if 'actions' in self.sequence:
self.sequence.remove('actions')
self.sequence.append('actions')
# Dynamically update the table's QuerySet to ensure related fields are pre-fetched
@@ -128,7 +130,7 @@ class BaseTable(tables.Table):
prefixes/IP addresses/etc., where some table rows may represent available address space.
"""
if not hasattr(self, '_objects_count'):
self._objects_count = sum(1 for obj in self.data if getattr(obj, 'pk'))
self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk'))
return self._objects_count

View File

@@ -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()

View File

@@ -1,5 +1,5 @@
Django==3.2.7
django-cors-headers==3.9.0
Django==3.2.8
django-cors-headers==3.10.0
django-debug-toolbar==3.2.2
django-filter==21.1
django-graphiql-debug-toolbar==0.2.0
@@ -8,21 +8,21 @@ django-pglocks==1.0.4
django-prometheus==2.1.0
django-redis==5.0.0
django-rq==2.4.1
django-tables2==2.4.0
django-tables2==2.4.1
django-taggit==1.5.1
django-timezone-field==4.2.1
djangorestframework==3.12.4
drf-yasg[validation]==1.20.0
graphene_django==2.15.0
gunicorn==20.1.0
Jinja2==3.0.1
Jinja2==3.0.2
Markdown==3.3.4
markdown-include==0.6.0
mkdocs-material==7.3.1
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