mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 09:51:22 -06:00
Merge branch 'develop' into feature
This commit is contained in:
commit
d52105b3b8
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@ -39,13 +39,25 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
|
- name: Install Yarn Package Manager
|
||||||
|
run: npm install -g yarn
|
||||||
|
|
||||||
|
- name: Setup Node.js with Yarn Caching
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
cache: yarn
|
||||||
|
cache-dependency-path: netbox/project-static/yarn.lock
|
||||||
|
|
||||||
|
- name: Install Frontend Dependencies
|
||||||
|
run: yarn --cwd netbox/project-static
|
||||||
|
|
||||||
- name: Install dependencies & set up configuration
|
- name: Install dependencies & set up configuration
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
pip install pycodestyle coverage
|
pip install pycodestyle coverage
|
||||||
ln -s configuration.testing.py netbox/netbox/configuration.py
|
ln -s configuration.testing.py netbox/netbox/configuration.py
|
||||||
yarn --cwd netbox/project-static
|
|
||||||
|
|
||||||
- name: Build documentation
|
- name: Build documentation
|
||||||
run: mkdocs build
|
run: mkdocs build
|
||||||
@ -63,7 +75,7 @@ jobs:
|
|||||||
run: scripts/verify-bundles.sh
|
run: scripts/verify-bundles.sh
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: coverage run --source="netbox/" netbox/manage.py test netbox/
|
run: coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel
|
||||||
|
|
||||||
- name: Show coverage report
|
- name: Show coverage report
|
||||||
run: coverage report --skip-covered --omit *migrations*
|
run: coverage report --skip-covered --omit *migrations*
|
||||||
|
@ -16,13 +16,6 @@ categories for discussions:
|
|||||||
feature request
|
feature request
|
||||||
* **Q&A** - Request help with installing or using NetBox
|
* **Q&A** - Request help with installing or using NetBox
|
||||||
|
|
||||||
### Mailing List
|
|
||||||
|
|
||||||
We also have a Google Groups [mailing list](https://groups.google.com/g/netbox-discuss)
|
|
||||||
for general discussion, however we're encouraging people to use GitHub
|
|
||||||
discussions where possible, as it's much easier for newcomers to review past
|
|
||||||
discussions.
|
|
||||||
|
|
||||||
### Slack
|
### Slack
|
||||||
|
|
||||||
For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://netdev.chat/).
|
For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://netdev.chat/).
|
||||||
|
@ -68,7 +68,6 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne
|
|||||||
|
|
||||||
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
|
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
|
||||||
* [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
|
* [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
|
||||||
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being replaced by GitHub discussions
|
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ The list of groups to assign a new user account when created using remote authen
|
|||||||
|
|
||||||
Default: `{}` (Empty dictionary)
|
Default: `{}` (Empty dictionary)
|
||||||
|
|
||||||
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.)
|
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED` as True and `REMOTE_AUTH_GROUP_SYNC_ENABLED` as False.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -43,7 +43,7 @@ A mapping of permissions to assign a new user account when created using remote
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.)
|
NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (`REMOTE_AUTH_DEFAULT_GROUPS` will not function if `REMOTE_AUTH_ENABLED` is enabled)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -7,9 +7,8 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
|
|||||||
There are several official forums for communication among the developers and community members:
|
There are several official forums for communication among the developers and community members:
|
||||||
|
|
||||||
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in a GitHub issue.
|
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in a GitHub issue.
|
||||||
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
|
* [GitHub discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
|
||||||
* [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
|
* [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
|
||||||
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions.
|
|
||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
|
|
||||||
|
@ -1,5 +1,23 @@
|
|||||||
# NetBox v3.1
|
# NetBox v3.1
|
||||||
|
|
||||||
|
## v3.1.8 (FUTURE)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#7150](https://github.com/netbox-community/netbox/issues/7150) - Linkify devices on the far side of a rack elevation
|
||||||
|
* [#8398](https://github.com/netbox-community/netbox/issues/8398) - Embiggen configuration form fields for banner message content
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#8331](https://github.com/netbox-community/netbox/issues/8331) - Implement `replaceAll` string utility function to improve browser compatibility
|
||||||
|
* [#8548](https://github.com/netbox-community/netbox/issues/8548) - Fix display of VC members when position is zero
|
||||||
|
* [#8561](https://github.com/netbox-community/netbox/issues/8561) - Include option to connect a rear port to a console port
|
||||||
|
* [#8564](https://github.com/netbox-community/netbox/issues/8564) - Fix errant table configuration key `available_columns`
|
||||||
|
* [#8578](https://github.com/netbox-community/netbox/issues/8578) - Object change log tables should honor user's configured preferences
|
||||||
|
* [#8604](https://github.com/netbox-community/netbox/issues/8604) - Fix tag filter on config context list filter form
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v3.1.7 (2022-02-03)
|
## v3.1.7 (2022-02-03)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
@ -126,10 +126,16 @@ class RackElevationSVG:
|
|||||||
link.add(drawing.text(str(name), insert=text, fill='white', 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):
|
def _draw_device_rear(self, drawing, device, start, end, text):
|
||||||
rect = drawing.rect(start, end, class_="slot blocked")
|
link = drawing.add(
|
||||||
rect.set_desc(self._get_device_description(device))
|
drawing.a(
|
||||||
drawing.add(rect)
|
href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
|
||||||
drawing.add(drawing.text(get_device_name(device), insert=text))
|
target='_top',
|
||||||
|
fill='black'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
link.set_desc(self._get_device_description(device))
|
||||||
|
link.add(drawing.rect(start, end, class_="slot blocked"))
|
||||||
|
link.add(drawing.text(get_device_name(device), insert=text))
|
||||||
|
|
||||||
# Embed rear device type image if one exists
|
# Embed rear device type image if one exists
|
||||||
if self.include_images and device.device_type.rear_image:
|
if self.include_images and device.device_type.rear_image:
|
||||||
|
@ -311,6 +311,8 @@ REARPORT_BUTTONS = """
|
|||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
|
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
|
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
|
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
|
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
|
||||||
|
@ -330,6 +330,11 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Tenant (slug)',
|
label='Tenant (slug)',
|
||||||
)
|
)
|
||||||
|
tag_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='tags',
|
||||||
|
queryset=Tag.objects.all(),
|
||||||
|
label='Tag',
|
||||||
|
)
|
||||||
tag = django_filters.ModelMultipleChoiceFilter(
|
tag = django_filters.ModelMultipleChoiceFilter(
|
||||||
field_name='tags__slug',
|
field_name='tags__slug',
|
||||||
queryset=Tag.objects.all(),
|
queryset=Tag.objects.all(),
|
||||||
|
@ -160,7 +160,7 @@ class TagFilterForm(FilterForm):
|
|||||||
|
|
||||||
class ConfigContextFilterForm(FilterForm):
|
class ConfigContextFilterForm(FilterForm):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'tag')),
|
(None, ('q', 'tag_id')),
|
||||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
('Location', ('region_id', 'site_group_id', 'site_id')),
|
||||||
('Device', ('device_type_id', 'platform_id', 'role_id')),
|
('Device', ('device_type_id', 'platform_id', 'role_id')),
|
||||||
('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
|
('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
|
||||||
@ -222,9 +222,8 @@ class ConfigContextFilterForm(FilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Tenant')
|
label=_('Tenant')
|
||||||
)
|
)
|
||||||
tag = DynamicModelMultipleChoiceField(
|
tag_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Tag.objects.all(),
|
queryset=Tag.objects.all(),
|
||||||
to_field_name='slug',
|
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Tags')
|
label=_('Tags')
|
||||||
)
|
)
|
||||||
|
@ -12,7 +12,7 @@ from extras.filtersets import *
|
|||||||
from extras.models import *
|
from extras.models import *
|
||||||
from ipam.models import IPAddress
|
from ipam.models import IPAddress
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests
|
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
|
|
||||||
|
|
||||||
@ -444,6 +444,8 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
)
|
)
|
||||||
Tenant.objects.bulk_create(tenants)
|
Tenant.objects.bulk_create(tenants)
|
||||||
|
|
||||||
|
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||||
|
|
||||||
for i in range(0, 3):
|
for i in range(0, 3):
|
||||||
is_active = bool(i % 2)
|
is_active = bool(i % 2)
|
||||||
c = ConfigContext.objects.create(
|
c = ConfigContext.objects.create(
|
||||||
@ -462,6 +464,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
c.clusters.set([clusters[i]])
|
c.clusters.set([clusters[i]])
|
||||||
c.tenant_groups.set([tenant_groups[i]])
|
c.tenant_groups.set([tenant_groups[i]])
|
||||||
c.tenants.set([tenants[i]])
|
c.tenants.set([tenants[i]])
|
||||||
|
c.tags.set([tags[i]])
|
||||||
|
|
||||||
def test_name(self):
|
def test_name(self):
|
||||||
params = {'name': ['Config Context 1', 'Config Context 2']}
|
params = {'name': ['Config Context 1', 'Config Context 2']}
|
||||||
@ -539,13 +542,20 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_tenant_(self):
|
def test_tenant(self):
|
||||||
tenants = Tenant.objects.all()[:2]
|
tenants = Tenant.objects.all()[:2]
|
||||||
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
|
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
|
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_tags(self):
|
||||||
|
tags = Tag.objects.all()[:2]
|
||||||
|
params = {'tag_id': [tags[0].pk, tags[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'tag': [tags[0].slug, tags[1].slug]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
queryset = Tag.objects.all()
|
queryset = Tag.objects.all()
|
||||||
|
@ -20,19 +20,28 @@ PARAMS = (
|
|||||||
name='BANNER_LOGIN',
|
name='BANNER_LOGIN',
|
||||||
label='Login banner',
|
label='Login banner',
|
||||||
default='',
|
default='',
|
||||||
description="Additional content to display on the login page"
|
description="Additional content to display on the login page",
|
||||||
|
field_kwargs={
|
||||||
|
'widget': forms.Textarea(),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
ConfigParam(
|
ConfigParam(
|
||||||
name='BANNER_TOP',
|
name='BANNER_TOP',
|
||||||
label='Top banner',
|
label='Top banner',
|
||||||
default='',
|
default='',
|
||||||
description="Additional content to display at the top of every page"
|
description="Additional content to display at the top of every page",
|
||||||
|
field_kwargs={
|
||||||
|
'widget': forms.Textarea(),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
ConfigParam(
|
ConfigParam(
|
||||||
name='BANNER_BOTTOM',
|
name='BANNER_BOTTOM',
|
||||||
label='Bottom banner',
|
label='Bottom banner',
|
||||||
default='',
|
default='',
|
||||||
description="Additional content to display at the bottom of every page"
|
description="Additional content to display at the bottom of every page",
|
||||||
|
field_kwargs={
|
||||||
|
'widget': forms.Textarea(),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
# IPAM
|
# IPAM
|
||||||
|
@ -133,7 +133,7 @@ class HomeView(View):
|
|||||||
changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
|
changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
|
||||||
'user', 'changed_object_type'
|
'user', 'changed_object_type'
|
||||||
)[:10]
|
)[:10]
|
||||||
changelog_table = ObjectChangeTable(changelog)
|
changelog_table = ObjectChangeTable(changelog, user=request.user)
|
||||||
|
|
||||||
# Check whether a new release is available. (Only for staff/superusers.)
|
# Check whether a new release is available. (Only for staff/superusers.)
|
||||||
new_release = None
|
new_release = None
|
||||||
|
@ -42,7 +42,8 @@ class ObjectChangeLogView(View):
|
|||||||
)
|
)
|
||||||
objectchanges_table = tables.ObjectChangeTable(
|
objectchanges_table = tables.ObjectChangeTable(
|
||||||
data=objectchanges,
|
data=objectchanges,
|
||||||
orderable=False
|
orderable=False,
|
||||||
|
user=request.user
|
||||||
)
|
)
|
||||||
objectchanges_table.configure(request)
|
objectchanges_table.configure(request)
|
||||||
|
|
||||||
|
BIN
netbox/project-static/dist/config.js.map
vendored
BIN
netbox/project-static/dist/config.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/lldp.js.map
vendored
BIN
netbox/project-static/dist/lldp.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/status.js
vendored
BIN
netbox/project-static/dist/status.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/status.js.map
vendored
BIN
netbox/project-static/dist/status.js.map
vendored
Binary file not shown.
@ -8,11 +8,12 @@ import { DynamicParamsMap } from './dynamicParams';
|
|||||||
import { isStaticParams, isOption } from './types';
|
import { isStaticParams, isOption } from './types';
|
||||||
import {
|
import {
|
||||||
hasMore,
|
hasMore,
|
||||||
isTruthy,
|
|
||||||
hasError,
|
hasError,
|
||||||
getElement,
|
isTruthy,
|
||||||
getApiData,
|
getApiData,
|
||||||
|
getElement,
|
||||||
isApiError,
|
isApiError,
|
||||||
|
replaceAll,
|
||||||
createElement,
|
createElement,
|
||||||
uniqueByProperty,
|
uniqueByProperty,
|
||||||
findFirstAdjacent,
|
findFirstAdjacent,
|
||||||
@ -461,7 +462,7 @@ export class APISelect {
|
|||||||
// Set any primitive k/v pairs as data attributes on each option.
|
// Set any primitive k/v pairs as data attributes on each option.
|
||||||
for (const [k, v] of Object.entries(result)) {
|
for (const [k, v] of Object.entries(result)) {
|
||||||
if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
|
if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
|
||||||
const key = k.replaceAll('_', '-');
|
const key = replaceAll(k, '_', '-');
|
||||||
data[key] = String(v);
|
data[key] = String(v);
|
||||||
}
|
}
|
||||||
// Set option to disabled if the result contains a matching key and is truthy.
|
// Set option to disabled if the result contains a matching key and is truthy.
|
||||||
@ -659,7 +660,7 @@ export class APISelect {
|
|||||||
for (const [key, value] of this.pathValues.entries()) {
|
for (const [key, value] of this.pathValues.entries()) {
|
||||||
for (const result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
|
for (const result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
|
||||||
if (isTruthy(value)) {
|
if (isTruthy(value)) {
|
||||||
url = url.replaceAll(result[1], value.toString());
|
url = replaceAll(url, result[1], value.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -741,7 +742,7 @@ export class APISelect {
|
|||||||
* @param id DOM ID of the other element.
|
* @param id DOM ID of the other element.
|
||||||
*/
|
*/
|
||||||
private updatePathValues(id: string): void {
|
private updatePathValues(id: string): void {
|
||||||
const key = id.replaceAll(/^id_/gi, '');
|
const key = replaceAll(id, /^id_/i, '');
|
||||||
const element = getElement<HTMLSelectElement>(`id_${key}`);
|
const element = getElement<HTMLSelectElement>(`id_${key}`);
|
||||||
if (element !== null) {
|
if (element !== null) {
|
||||||
// If this element's URL contains Django template tags ({{), replace the template tag
|
// If this element's URL contains Django template tags ({{), replace the template tag
|
||||||
@ -919,16 +920,18 @@ export class APISelect {
|
|||||||
style.setAttribute('data-netbox', id);
|
style.setAttribute('data-netbox', id);
|
||||||
|
|
||||||
// Scope the CSS to apply both the list item and the selected item.
|
// Scope the CSS to apply both the list item and the selected item.
|
||||||
style.innerHTML = `
|
style.innerHTML = replaceAll(
|
||||||
|
`
|
||||||
div.ss-values div.ss-value[data-id="${id}"],
|
div.ss-values div.ss-value[data-id="${id}"],
|
||||||
div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
|
div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
|
||||||
{
|
{
|
||||||
background-color: ${bg} !important;
|
background-color: ${bg} !important;
|
||||||
color: ${fg} !important;
|
color: ${fg} !important;
|
||||||
}
|
}
|
||||||
`
|
`,
|
||||||
.replaceAll('\n', '')
|
'\n',
|
||||||
.trim();
|
'',
|
||||||
|
).trim();
|
||||||
|
|
||||||
// Add the style element to the DOM.
|
// Add the style element to the DOM.
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
@ -11,15 +11,6 @@ function saveTableConfig(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete all selected columns, which reverts the user's preferences to the default column set.
|
|
||||||
*/
|
|
||||||
function resetTableConfig(): void {
|
|
||||||
for (const element of getElements<HTMLSelectElement>('select[name="columns"]')) {
|
|
||||||
element.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add columns to the table config select element.
|
* Add columns to the table config select element.
|
||||||
*/
|
*/
|
||||||
@ -53,7 +44,10 @@ function removeColumns(event: Event): void {
|
|||||||
/**
|
/**
|
||||||
* Submit form configuration to the NetBox API.
|
* Submit form configuration to the NetBox API.
|
||||||
*/
|
*/
|
||||||
async function submitFormConfig(url: string, formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> {
|
async function submitFormConfig(
|
||||||
|
url: string,
|
||||||
|
formConfig: Dict<Dict>,
|
||||||
|
): Promise<APIResponse<APIUserConfig>> {
|
||||||
return await apiPatch<APIUserConfig>(url, formConfig);
|
return await apiPatch<APIUserConfig>(url, formConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,23 +66,44 @@ function handleSubmit(event: Event): void {
|
|||||||
const toast = createToast(
|
const toast = createToast(
|
||||||
'danger',
|
'danger',
|
||||||
'Error Updating Table Configuration',
|
'Error Updating Table Configuration',
|
||||||
'No API path defined for configuration form.'
|
'No API path defined for configuration form.',
|
||||||
);
|
);
|
||||||
toast.show();
|
toast.show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine if the form action is to reset the table config.
|
||||||
|
const reset = document.activeElement?.getAttribute('value') === 'Reset';
|
||||||
|
|
||||||
|
// Create an array from the dot-separated config path. E.g. tables.DevicePowerOutletTable becomes
|
||||||
|
// ['tables', 'DevicePowerOutletTable']
|
||||||
|
const path = element.getAttribute('data-config-root')?.split('.') ?? [];
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
// If we're resetting the table config, create an empty object for this table. E.g.
|
||||||
|
// tables.PlatformTable becomes {tables: PlatformTable: {}}
|
||||||
|
const data = path.reduceRight<Dict<Dict>>((value, key) => ({ [key]: value }), {});
|
||||||
|
|
||||||
|
// Submit the reset for configuration to the API.
|
||||||
|
submitFormConfig(url, data).then(res => {
|
||||||
|
if (hasError(res)) {
|
||||||
|
const toast = createToast('danger', 'Error Resetting Table Configuration', res.error);
|
||||||
|
toast.show();
|
||||||
|
} else {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get all the selected options from any select element in the form.
|
// Get all the selected options from any select element in the form.
|
||||||
const options = getSelectedOptions(element);
|
const options = getSelectedOptions(element, 'select[name=columns]');
|
||||||
|
|
||||||
// Create an object mapping the select element's name to all selected options for that element.
|
// Create an object mapping the select element's name to all selected options for that element.
|
||||||
const formData: Dict<Dict<string>> = Object.assign(
|
const formData: Dict<Dict<string>> = Object.assign(
|
||||||
{},
|
{},
|
||||||
...options.map(opt => ({ [opt.name]: opt.options })),
|
...options.map(opt => ({ [opt.name]: opt.options })),
|
||||||
);
|
);
|
||||||
// Create an array from the dot-separated config path. E.g. tables.DevicePowerOutletTable becomes
|
|
||||||
// ['tables', 'DevicePowerOutletTable']
|
|
||||||
const path = element.getAttribute('data-config-root')?.split('.') ?? [];
|
|
||||||
|
|
||||||
// Create an object mapping the configuration path to the select element names, which contain the
|
// Create an object mapping the configuration path to the select element names, which contain the
|
||||||
// selection options. E.g. {tables: {DevicePowerOutletTable: {columns: ['label', 'type']}}}
|
// selection options. E.g. {tables: {DevicePowerOutletTable: {columns: ['label', 'type']}}}
|
||||||
@ -112,9 +127,6 @@ export function initTableConfig(): void {
|
|||||||
for (const element of getElements<HTMLButtonElement>('#save_tableconfig')) {
|
for (const element of getElements<HTMLButtonElement>('#save_tableconfig')) {
|
||||||
element.addEventListener('click', saveTableConfig);
|
element.addEventListener('click', saveTableConfig);
|
||||||
}
|
}
|
||||||
for (const element of getElements<HTMLButtonElement>('#reset_tableconfig')) {
|
|
||||||
element.addEventListener('click', resetTableConfig);
|
|
||||||
}
|
|
||||||
for (const element of getElements<HTMLButtonElement>('#add_columns')) {
|
for (const element of getElements<HTMLButtonElement>('#add_columns')) {
|
||||||
element.addEventListener('click', addColumns);
|
element.addEventListener('click', addColumns);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { getElements, findFirstAdjacent } from '../util';
|
import { getElements, replaceAll, findFirstAdjacent } from '../util';
|
||||||
|
|
||||||
type InterfaceState = 'enabled' | 'disabled';
|
type InterfaceState = 'enabled' | 'disabled';
|
||||||
type ShowHide = 'show' | 'hide';
|
type ShowHide = 'show' | 'hide';
|
||||||
@ -105,9 +105,9 @@ class ButtonState {
|
|||||||
*/
|
*/
|
||||||
private toggleButton(): void {
|
private toggleButton(): void {
|
||||||
if (this.buttonState === 'show') {
|
if (this.buttonState === 'show') {
|
||||||
this.button.innerText = this.button.innerText.replaceAll('Show', 'Hide');
|
this.button.innerText = replaceAll(this.button.innerText, 'Show', 'Hide');
|
||||||
} else if (this.buttonState === 'hide') {
|
} else if (this.buttonState === 'hide') {
|
||||||
this.button.innerText = this.button.innerText.replaceAll('Hide', 'Show');
|
this.button.innerText = replaceAll(this.button.innerHTML, 'Hide', 'Show');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -231,11 +231,15 @@ export function scrollTo(element: Element, offset: number = 0): void {
|
|||||||
* Iterate through a select element's options and return an array of options that are selected.
|
* Iterate through a select element's options and return an array of options that are selected.
|
||||||
*
|
*
|
||||||
* @param base Select element.
|
* @param base Select element.
|
||||||
|
* @param selector Optionally specify a selector. 'select' by default.
|
||||||
* @returns Array of selected options.
|
* @returns Array of selected options.
|
||||||
*/
|
*/
|
||||||
export function getSelectedOptions<E extends HTMLElement>(base: E): SelectedOption[] {
|
export function getSelectedOptions<E extends HTMLElement>(
|
||||||
|
base: E,
|
||||||
|
selector: string = 'select',
|
||||||
|
): SelectedOption[] {
|
||||||
let selected = [] as SelectedOption[];
|
let selected = [] as SelectedOption[];
|
||||||
for (const element of base.querySelectorAll<HTMLSelectElement>('select')) {
|
for (const element of base.querySelectorAll<HTMLSelectElement>(selector)) {
|
||||||
if (element !== null) {
|
if (element !== null) {
|
||||||
const select = { name: element.name, options: [] } as SelectedOption;
|
const select = { name: element.name, options: [] } as SelectedOption;
|
||||||
for (const option of element.options) {
|
for (const option of element.options) {
|
||||||
@ -315,7 +319,7 @@ export function* getRowValues(table: HTMLTableRowElement): Generator<string> {
|
|||||||
for (const element of table.querySelectorAll<HTMLTableCellElement>('td')) {
|
for (const element of table.querySelectorAll<HTMLTableCellElement>('td')) {
|
||||||
if (element !== null) {
|
if (element !== null) {
|
||||||
if (isTruthy(element.innerText) && element.innerText !== '—') {
|
if (isTruthy(element.innerText) && element.innerText !== '—') {
|
||||||
yield element.innerText.replaceAll(/[\n\r]/g, '').trim();
|
yield replaceAll(element.innerText, '[\n\r]', '').trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -436,3 +440,49 @@ export function uniqueByProperty<T extends unknown, P extends keyof T>(arr: T[],
|
|||||||
}
|
}
|
||||||
return Array.from(baseMap.values());
|
return Array.from(baseMap.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace all occurrences of a pattern with a replacement string.
|
||||||
|
*
|
||||||
|
* This is a browser-compatibility-focused drop-in replacement for `String.prototype.replaceAll()`,
|
||||||
|
* introduced in ES2021.
|
||||||
|
*
|
||||||
|
* @param input string to be processed.
|
||||||
|
* @param pattern regex pattern string or RegExp object to search for.
|
||||||
|
* @param replacement replacement substring with which `pattern` matches will be replaced.
|
||||||
|
* @returns processed version of `input`.
|
||||||
|
*/
|
||||||
|
export function replaceAll(input: string, pattern: string | RegExp, replacement: string): string {
|
||||||
|
// Ensure input is a string.
|
||||||
|
if (typeof input !== 'string') {
|
||||||
|
throw new TypeError("replaceAll 'input' argument must be a string");
|
||||||
|
}
|
||||||
|
// Ensure pattern is a string or RegExp.
|
||||||
|
if (typeof pattern !== 'string' && !(pattern instanceof RegExp)) {
|
||||||
|
throw new TypeError("replaceAll 'pattern' argument must be a string or RegExp instance");
|
||||||
|
}
|
||||||
|
// Ensure replacement is able to be stringified.
|
||||||
|
switch (typeof replacement) {
|
||||||
|
case 'boolean':
|
||||||
|
replacement = String(replacement);
|
||||||
|
break;
|
||||||
|
case 'number':
|
||||||
|
replacement = String(replacement);
|
||||||
|
break;
|
||||||
|
case 'string':
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new TypeError("replaceAll 'replacement' argument must be stringifyable");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pattern instanceof RegExp) {
|
||||||
|
// Add global flag to existing RegExp object and deduplicate
|
||||||
|
const flags = Array.from(new Set([...pattern.flags.split(''), 'g'])).join('');
|
||||||
|
pattern = new RegExp(pattern.source, flags);
|
||||||
|
} else {
|
||||||
|
// Create a RegExp object with the global flag set.
|
||||||
|
pattern = new RegExp(pattern, 'g');
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.replace(pattern, replacement);
|
||||||
|
}
|
||||||
|
@ -33,6 +33,7 @@
|
|||||||
<a href="{{ object.site.get_absolute_url }}">{{ object.site }}</a>
|
<a href="{{ object.site.get_absolute_url }}">{{ object.site }}</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
<th scope="row">Location</th>
|
<th scope="row">Location</th>
|
||||||
<td>
|
<td>
|
||||||
{% if object.location %}
|
{% if object.location %}
|
||||||
@ -129,7 +130,7 @@
|
|||||||
<a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
|
<a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% badge vc_member.vc_position %}
|
{% badge vc_member.vc_position show_empty=True %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}
|
{% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}
|
||||||
|
Loading…
Reference in New Issue
Block a user