mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-19 03:42:25 -06:00
Merge branch 'feature' into 9856-strawberry-2
This commit is contained in:
@@ -119,10 +119,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
|
||||
user = DynamicModelMultipleChoiceField(
|
||||
queryset=get_user_model().objects.all(),
|
||||
required=False,
|
||||
label=_('User'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
)
|
||||
label=_('User')
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1288,18 +1288,6 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
to_field_name='name',
|
||||
label=_('Virtual Chassis'),
|
||||
)
|
||||
# TODO: Remove in v4.0
|
||||
device_role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__role',
|
||||
queryset=DeviceRole.objects.all(),
|
||||
label=_('Device role (ID)'),
|
||||
)
|
||||
device_role = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__role__slug',
|
||||
queryset=DeviceRole.objects.all(),
|
||||
to_field_name='slug',
|
||||
label=_('Device role (slug)'),
|
||||
)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -393,10 +393,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
user_id = DynamicModelMultipleChoiceField(
|
||||
queryset=get_user_model().objects.all(),
|
||||
required=False,
|
||||
label=_('User'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
)
|
||||
label=_('User')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -551,8 +548,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label=_('Manufacturer'),
|
||||
fetch_trigger='open'
|
||||
label=_('Manufacturer')
|
||||
)
|
||||
part_number = forms.CharField(
|
||||
label=_('Part number'),
|
||||
@@ -828,8 +824,7 @@ class VirtualDeviceContextFilterForm(
|
||||
device = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
label=_('Device'),
|
||||
fetch_trigger='open'
|
||||
label=_('Device')
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
label=_('Status'),
|
||||
@@ -855,8 +850,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label=_('Manufacturer'),
|
||||
fetch_trigger='open'
|
||||
label=_('Manufacturer')
|
||||
)
|
||||
module_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ModuleType.objects.all(),
|
||||
@@ -864,8 +858,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
|
||||
query_params={
|
||||
'manufacturer_id': '$manufacturer_id'
|
||||
},
|
||||
label=_('Type'),
|
||||
fetch_trigger='open'
|
||||
label=_('Type')
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
label=_('Status'),
|
||||
@@ -1414,8 +1407,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=InventoryItemRole.objects.all(),
|
||||
required=False,
|
||||
label=_('Role'),
|
||||
fetch_trigger='open'
|
||||
label=_('Role')
|
||||
)
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
|
||||
@@ -50,8 +50,6 @@ __all__ = (
|
||||
'SavedFilterSerializer',
|
||||
'ScriptDetailSerializer',
|
||||
'ScriptInputSerializer',
|
||||
'ScriptLogMessageSerializer',
|
||||
'ScriptOutputSerializer',
|
||||
'ScriptSerializer',
|
||||
'TagSerializer',
|
||||
'WebhookSerializer',
|
||||
@@ -604,22 +602,6 @@ class ScriptInputSerializer(serializers.Serializer):
|
||||
return value
|
||||
|
||||
|
||||
class ScriptLogMessageSerializer(serializers.Serializer):
|
||||
status = serializers.SerializerMethodField(read_only=True)
|
||||
message = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
def get_status(self, instance):
|
||||
return instance[0]
|
||||
|
||||
def get_message(self, instance):
|
||||
return instance[1]
|
||||
|
||||
|
||||
class ScriptOutputSerializer(serializers.Serializer):
|
||||
log = ScriptLogMessageSerializer(many=True, read_only=True)
|
||||
output = serializers.CharField(read_only=True)
|
||||
|
||||
|
||||
#
|
||||
# Change logging
|
||||
#
|
||||
|
||||
@@ -20,7 +20,6 @@ router.register('image-attachments', views.ImageAttachmentViewSet)
|
||||
router.register('journal-entries', views.JournalEntryViewSet)
|
||||
router.register('config-contexts', views.ConfigContextViewSet)
|
||||
router.register('config-templates', views.ConfigTemplateViewSet)
|
||||
router.register('reports', views.ReportViewSet, basename='report')
|
||||
router.register('scripts', views.ScriptViewSet, basename='script')
|
||||
router.register('object-changes', views.ObjectChangeViewSet)
|
||||
router.register('content-types', views.ContentTypeViewSet)
|
||||
|
||||
@@ -16,7 +16,6 @@ from core.choices import JobStatusChoices
|
||||
from core.models import Job
|
||||
from extras import filtersets
|
||||
from extras.models import *
|
||||
from extras.reports import get_module_and_report, run_report
|
||||
from extras.scripts import get_module_and_script, run_script
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.features import SyncedDataMixin
|
||||
@@ -211,111 +210,6 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
|
||||
return self.render_configtemplate(request, configtemplate, context)
|
||||
|
||||
|
||||
#
|
||||
# Reports
|
||||
#
|
||||
|
||||
class ReportViewSet(ViewSet):
|
||||
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
||||
_ignore_model_permissions = True
|
||||
schema = None
|
||||
lookup_value_regex = '[^/]+' # Allow dots
|
||||
|
||||
def _get_report(self, pk):
|
||||
try:
|
||||
module_name, report_name = pk.split('.', maxsplit=1)
|
||||
except ValueError:
|
||||
raise Http404
|
||||
|
||||
module, report = get_module_and_report(module_name, report_name)
|
||||
if report is None:
|
||||
raise Http404
|
||||
|
||||
return module, report
|
||||
|
||||
def list(self, request):
|
||||
"""
|
||||
Compile all reports and their related results (if any). Result data is deferred in the list view.
|
||||
"""
|
||||
results = {
|
||||
job.name: job
|
||||
for job in Job.objects.filter(
|
||||
object_type=ContentType.objects.get(app_label='extras', model='reportmodule'),
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).order_by('name', '-created').distinct('name').defer('data')
|
||||
}
|
||||
|
||||
report_list = []
|
||||
for report_module in ReportModule.objects.restrict(request.user):
|
||||
report_list.extend([report() for report in report_module.reports.values()])
|
||||
|
||||
# Attach Job objects to each report (if any)
|
||||
for report in report_list:
|
||||
report.result = results.get(report.name, None)
|
||||
|
||||
serializer = serializers.ReportSerializer(report_list, many=True, context={
|
||||
'request': request,
|
||||
})
|
||||
|
||||
return Response({'count': len(report_list), 'results': serializer.data})
|
||||
|
||||
def retrieve(self, request, pk):
|
||||
"""
|
||||
Retrieve a single Report identified as "<module>.<report>".
|
||||
"""
|
||||
module, report = self._get_report(pk)
|
||||
|
||||
# Retrieve the Report and Job, if any.
|
||||
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
|
||||
report.result = Job.objects.filter(
|
||||
object_type=object_type,
|
||||
name=report.name,
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).first()
|
||||
|
||||
serializer = serializers.ReportDetailSerializer(report, context={
|
||||
'request': request
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def run(self, request, pk):
|
||||
"""
|
||||
Run a Report identified as "<module>.<script>" and return the pending Job as the result
|
||||
"""
|
||||
# Check that the user has permission to run reports.
|
||||
if not request.user.has_perm('extras.run_report'):
|
||||
raise PermissionDenied("This user does not have permission to run reports.")
|
||||
|
||||
# Check that at least one RQ worker is running
|
||||
if not Worker.count(get_connection('default')):
|
||||
raise RQWorkerNotRunningException()
|
||||
|
||||
# Retrieve and run the Report. This will create a new Job.
|
||||
module, report_cls = self._get_report(pk)
|
||||
report = report_cls
|
||||
input_serializer = serializers.ReportInputSerializer(
|
||||
data=request.data,
|
||||
context={'report': report}
|
||||
)
|
||||
|
||||
if input_serializer.is_valid():
|
||||
report.result = Job.enqueue(
|
||||
run_report,
|
||||
instance=module,
|
||||
name=report.class_name,
|
||||
user=request.user,
|
||||
job_timeout=report.job_timeout,
|
||||
schedule_at=input_serializer.validated_data.get('schedule_at'),
|
||||
interval=input_serializer.validated_data.get('interval')
|
||||
)
|
||||
serializer = serializers.ReportDetailSerializer(report, context={'request': request})
|
||||
|
||||
return Response(serializer.data)
|
||||
return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
#
|
||||
# Scripts
|
||||
#
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from utilities.choices import ButtonColorChoices, ChoiceSet
|
||||
@@ -164,6 +166,7 @@ class JournalEntryKindChoices(ChoiceSet):
|
||||
|
||||
class LogLevelChoices(ChoiceSet):
|
||||
|
||||
LOG_DEBUG = 'debug'
|
||||
LOG_DEFAULT = 'default'
|
||||
LOG_SUCCESS = 'success'
|
||||
LOG_INFO = 'info'
|
||||
@@ -171,6 +174,7 @@ class LogLevelChoices(ChoiceSet):
|
||||
LOG_FAILURE = 'failure'
|
||||
|
||||
CHOICES = (
|
||||
(LOG_DEBUG, _('Debug'), 'teal'),
|
||||
(LOG_DEFAULT, _('Default'), 'gray'),
|
||||
(LOG_SUCCESS, _('Success'), 'green'),
|
||||
(LOG_INFO, _('Info'), 'cyan'),
|
||||
@@ -178,6 +182,15 @@ class LogLevelChoices(ChoiceSet):
|
||||
(LOG_FAILURE, _('Failure'), 'red'),
|
||||
)
|
||||
|
||||
SYSTEM_LEVELS = {
|
||||
LOG_DEBUG: logging.DEBUG,
|
||||
LOG_DEFAULT: logging.INFO,
|
||||
LOG_SUCCESS: logging.INFO,
|
||||
LOG_INFO: logging.INFO,
|
||||
LOG_WARNING: logging.WARNING,
|
||||
LOG_FAILURE: logging.ERROR,
|
||||
}
|
||||
|
||||
|
||||
class DurationChoices(ChoiceSet):
|
||||
|
||||
|
||||
@@ -381,8 +381,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||
cluster_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ClusterType.objects.all(),
|
||||
required=False,
|
||||
label=_('Cluster types'),
|
||||
fetch_trigger='open'
|
||||
label=_('Cluster types')
|
||||
)
|
||||
cluster_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
@@ -462,10 +461,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
|
||||
created_by_id = DynamicModelMultipleChoiceField(
|
||||
queryset=get_user_model().objects.all(),
|
||||
required=False,
|
||||
label=_('User'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
)
|
||||
label=_('User')
|
||||
)
|
||||
assigned_object_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
@@ -508,10 +504,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
||||
user_id = DynamicModelMultipleChoiceField(
|
||||
queryset=get_user_model().objects.all(),
|
||||
required=False,
|
||||
label=_('User'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
)
|
||||
label=_('User')
|
||||
)
|
||||
changed_object_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.models import Job
|
||||
from extras.models import ReportModule
|
||||
from extras.reports import run_report
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Run a report to validate data in NetBox"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('reports', nargs='+', help="Report(s) to run")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
||||
for module in ReportModule.objects.all():
|
||||
for report in module.reports.values():
|
||||
if module.name in options['reports'] or report.full_name in options['reports']:
|
||||
|
||||
# Run the report and create a new Job
|
||||
self.stdout.write(
|
||||
"[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
|
||||
)
|
||||
|
||||
job = Job.enqueue(
|
||||
run_report,
|
||||
instance=module,
|
||||
name=report.class_name,
|
||||
job_timeout=report.job_timeout
|
||||
)
|
||||
|
||||
# Wait on the job to finish
|
||||
while job.status not in JobStatusChoices.TERMINAL_STATE_CHOICES:
|
||||
time.sleep(1)
|
||||
job = Job.objects.get(pk=job.pk)
|
||||
|
||||
# Report on success/failure
|
||||
if job.status == JobStatusChoices.STATUS_FAILED:
|
||||
status = self.style.ERROR('FAILED')
|
||||
elif job == JobStatusChoices.STATUS_ERRORED:
|
||||
status = self.style.ERROR('ERRORED')
|
||||
else:
|
||||
status = self.style.SUCCESS('SUCCESS')
|
||||
|
||||
for test_name, attrs in job.data.items():
|
||||
self.stdout.write(
|
||||
"\t{}: {} success, {} info, {} warning, {} failure".format(
|
||||
test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
|
||||
)
|
||||
)
|
||||
self.stdout.write(
|
||||
"[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status)
|
||||
)
|
||||
self.stdout.write(
|
||||
"[{:%H:%M:%S}] {}: Duration {}".format(timezone.now(), report.full_name, job.duration)
|
||||
)
|
||||
|
||||
# Wrap things up
|
||||
self.stdout.write(
|
||||
"[{:%H:%M:%S}] Finished".format(timezone.now())
|
||||
)
|
||||
@@ -10,7 +10,6 @@ from django.db import transaction
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.models import Job
|
||||
from extras.api.serializers import ScriptOutputSerializer
|
||||
from extras.context_managers import event_tracking
|
||||
from extras.scripts import get_module_and_script
|
||||
from extras.signals import clear_events
|
||||
@@ -34,6 +33,7 @@ class Command(BaseCommand):
|
||||
parser.add_argument('script', help="Script to run")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
||||
def _run_script():
|
||||
"""
|
||||
Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
|
||||
@@ -48,7 +48,7 @@ class Command(BaseCommand):
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
clear_events.send(request)
|
||||
job.data = ScriptOutputSerializer(script).data
|
||||
job.data = script.get_job_data()
|
||||
job.terminate()
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
@@ -58,9 +58,17 @@ class Command(BaseCommand):
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
clear_events.send(request)
|
||||
job.data = ScriptOutputSerializer(script).data
|
||||
job.data = script.get_job_data()
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
|
||||
|
||||
# Print any test method results
|
||||
for test_name, attrs in job.data['tests'].items():
|
||||
self.stdout.write(
|
||||
"\t{}: {} success, {} info, {} warning, {} failure".format(
|
||||
test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"Script completed in {job.duration}")
|
||||
|
||||
User = get_user_model()
|
||||
@@ -69,6 +77,7 @@ class Command(BaseCommand):
|
||||
script = options['script']
|
||||
loglevel = options['loglevel']
|
||||
commit = options['commit']
|
||||
|
||||
try:
|
||||
data = json.loads(options['data'])
|
||||
except TypeError:
|
||||
|
||||
31
netbox/extras/migrations/0107_convert_reports_to_scripts.py
Normal file
31
netbox/extras/migrations/0107_convert_reports_to_scripts.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def convert_reportmodule_jobs(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
Job = apps.get_model('core', 'Job')
|
||||
|
||||
# Convert all ReportModule jobs to ScriptModule jobs
|
||||
if reportmodule_ct := ContentType.objects.filter(app_label='extras', model='reportmodule').first():
|
||||
scriptmodule_ct = ContentType.objects.get(app_label='extras', model='scriptmodule')
|
||||
Job.objects.filter(object_type_id=reportmodule_ct.id).update(object_type_id=scriptmodule_ct.id)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0106_bookmark_user_cascade_deletion'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=convert_reportmodule_jobs,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Report',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='ReportModule',
|
||||
),
|
||||
]
|
||||
@@ -3,7 +3,6 @@ from .configs import *
|
||||
from .customfields import *
|
||||
from .dashboard import *
|
||||
from .models import *
|
||||
from .reports import *
|
||||
from .scripts import *
|
||||
from .search import *
|
||||
from .staging import *
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import inspect
|
||||
import logging
|
||||
from functools import cached_property
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.models import ManagedFile
|
||||
from extras.utils import is_report
|
||||
from netbox.models.features import JobsMixin, EventRulesMixin
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from .mixins import PythonModuleMixin
|
||||
|
||||
logger = logging.getLogger('netbox.reports')
|
||||
|
||||
__all__ = (
|
||||
'Report',
|
||||
'ReportModule',
|
||||
)
|
||||
|
||||
|
||||
class Report(EventRulesMixin, models.Model):
|
||||
"""
|
||||
Dummy model used to generate permissions for reports. Does not exist in the database.
|
||||
"""
|
||||
class Meta:
|
||||
managed = False
|
||||
|
||||
|
||||
class ReportModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(file_root=ManagedFileRootPathChoices.REPORTS)
|
||||
|
||||
|
||||
class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
"""
|
||||
Proxy model for report module files.
|
||||
"""
|
||||
objects = ReportModuleManager()
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
verbose_name = _('report module')
|
||||
verbose_name_plural = _('report modules')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:report_list')
|
||||
|
||||
def __str__(self):
|
||||
return self.python_name
|
||||
|
||||
@cached_property
|
||||
def reports(self):
|
||||
|
||||
def _get_name(cls):
|
||||
# For child objects in submodules use the full import path w/o the root module as the name
|
||||
return cls.full_name.split(".", maxsplit=1)[1]
|
||||
|
||||
try:
|
||||
module = self.get_module()
|
||||
except (ImportError, SyntaxError) as e:
|
||||
logger.error(f"Unable to load report module {self.name}, exception: {e}")
|
||||
return {}
|
||||
reports = {}
|
||||
ordered = getattr(module, 'report_order', [])
|
||||
|
||||
for cls in ordered:
|
||||
reports[_get_name(cls)] = cls
|
||||
for name, cls in inspect.getmembers(module, is_report):
|
||||
if cls not in ordered:
|
||||
reports[_get_name(cls)] = cls
|
||||
|
||||
return reports
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.file_root = ManagedFileRootPathChoices.REPORTS
|
||||
return super().save(*args, **kwargs)
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
from functools import cached_property
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -32,7 +33,8 @@ class Script(EventRulesMixin, models.Model):
|
||||
class ScriptModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(file_root=ManagedFileRootPathChoices.SCRIPTS)
|
||||
return super().get_queryset().filter(
|
||||
Q(file_root=ManagedFileRootPathChoices.SCRIPTS) | Q(file_root=ManagedFileRootPathChoices.REPORTS))
|
||||
|
||||
|
||||
class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
|
||||
@@ -1,248 +1,33 @@
|
||||
import inspect
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import classproperty
|
||||
from django_rq import job
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.models import Job
|
||||
from .choices import LogLevelChoices
|
||||
from .models import ReportModule
|
||||
from .scripts import BaseScript
|
||||
|
||||
__all__ = (
|
||||
'Report',
|
||||
'get_module_and_report',
|
||||
'run_report',
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_module_and_report(module_name, report_name):
|
||||
module = ReportModule.objects.get(file_path=f'{module_name}.py')
|
||||
report = module.reports.get(report_name)()
|
||||
return module, report
|
||||
|
||||
|
||||
@job('default')
|
||||
def run_report(job, *args, **kwargs):
|
||||
"""
|
||||
Helper function to call the run method on a report. This is needed to get around the inability to pickle an instance
|
||||
method for queueing into the background processor.
|
||||
"""
|
||||
job.start()
|
||||
|
||||
module = ReportModule.objects.get(pk=job.object_id)
|
||||
report = module.reports.get(job.name)()
|
||||
|
||||
try:
|
||||
report.run(job)
|
||||
except Exception as e:
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
|
||||
logging.error(f"Error during execution of report {job.name}")
|
||||
finally:
|
||||
# Schedule the next job if an interval has been set
|
||||
if job.interval:
|
||||
new_scheduled_time = job.scheduled + timedelta(minutes=job.interval)
|
||||
Job.enqueue(
|
||||
run_report,
|
||||
instance=job.object,
|
||||
name=job.name,
|
||||
user=job.user,
|
||||
job_timeout=report.job_timeout,
|
||||
schedule_at=new_scheduled_time,
|
||||
interval=job.interval
|
||||
)
|
||||
|
||||
|
||||
class Report(object):
|
||||
"""
|
||||
NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each
|
||||
report must have one or more test methods named `test_*`.
|
||||
|
||||
The `_results` attribute of a completed report will take the following form:
|
||||
|
||||
{
|
||||
'test_bar': {
|
||||
'failures': 42,
|
||||
'log': [
|
||||
(<datetime>, <level>, <object>, <message>),
|
||||
...
|
||||
]
|
||||
},
|
||||
'test_foo': {
|
||||
'failures': 0,
|
||||
'log': [
|
||||
(<datetime>, <level>, <object>, <message>),
|
||||
...
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
description = None
|
||||
scheduling_enabled = True
|
||||
job_timeout = None
|
||||
|
||||
def __init__(self):
|
||||
|
||||
self._results = {}
|
||||
self.active_test = None
|
||||
self.failed = False
|
||||
|
||||
self.logger = logging.getLogger(f"netbox.reports.{self.__module__}.{self.__class__.__name__}")
|
||||
|
||||
# Compile test methods and initialize results skeleton
|
||||
test_methods = []
|
||||
for method in dir(self):
|
||||
if method.startswith('test_') and callable(getattr(self, method)):
|
||||
test_methods.append(method)
|
||||
self._results[method] = {
|
||||
'success': 0,
|
||||
'info': 0,
|
||||
'warning': 0,
|
||||
'failure': 0,
|
||||
'log': [],
|
||||
}
|
||||
self.test_methods = test_methods
|
||||
|
||||
@classproperty
|
||||
def module(self):
|
||||
return self.__module__
|
||||
|
||||
@classproperty
|
||||
def class_name(self):
|
||||
return self.__name__
|
||||
|
||||
@classproperty
|
||||
def full_name(self):
|
||||
return f'{self.module}.{self.class_name}'
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""
|
||||
Override this attribute to set a custom display name.
|
||||
"""
|
||||
return self.class_name
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
return inspect.getfile(self.__class__)
|
||||
|
||||
@property
|
||||
def source(self):
|
||||
return inspect.getsource(self.__class__)
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
"""
|
||||
Indicates whether the report can be run.
|
||||
"""
|
||||
return bool(self.test_methods)
|
||||
class Report(BaseScript):
|
||||
|
||||
#
|
||||
# Logging methods
|
||||
# Legacy logging methods for Reports
|
||||
#
|
||||
|
||||
def _log(self, obj, message, level=LogLevelChoices.LOG_DEFAULT):
|
||||
"""
|
||||
Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.
|
||||
"""
|
||||
if level not in LogLevelChoices.values():
|
||||
raise Exception(f"Unknown logging level: {level}")
|
||||
self._results[self.active_test]['log'].append((
|
||||
timezone.now().isoformat(),
|
||||
level,
|
||||
str(obj) if obj else None,
|
||||
obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
|
||||
message,
|
||||
))
|
||||
|
||||
# There is no generic log() equivalent on BaseScript
|
||||
def log(self, message):
|
||||
"""
|
||||
Log a message which is not associated with a particular object.
|
||||
"""
|
||||
self._log(None, message, level=LogLevelChoices.LOG_DEFAULT)
|
||||
self.logger.info(message)
|
||||
self._log(message, None, level=LogLevelChoices.LOG_DEFAULT)
|
||||
|
||||
def log_success(self, obj, message=None):
|
||||
"""
|
||||
Record a successful test against an object. Logging a message is optional.
|
||||
"""
|
||||
if message:
|
||||
self._log(obj, message, level=LogLevelChoices.LOG_SUCCESS)
|
||||
self._results[self.active_test]['success'] += 1
|
||||
self.logger.info(f"Success | {obj}: {message}")
|
||||
def log_success(self, obj=None, message=None):
|
||||
super().log_success(message, obj)
|
||||
|
||||
def log_info(self, obj, message):
|
||||
"""
|
||||
Log an informational message.
|
||||
"""
|
||||
self._log(obj, message, level=LogLevelChoices.LOG_INFO)
|
||||
self._results[self.active_test]['info'] += 1
|
||||
self.logger.info(f"Info | {obj}: {message}")
|
||||
def log_info(self, obj=None, message=None):
|
||||
super().log_info(message, obj)
|
||||
|
||||
def log_warning(self, obj, message):
|
||||
"""
|
||||
Log a warning.
|
||||
"""
|
||||
self._log(obj, message, level=LogLevelChoices.LOG_WARNING)
|
||||
self._results[self.active_test]['warning'] += 1
|
||||
self.logger.info(f"Warning | {obj}: {message}")
|
||||
def log_warning(self, obj=None, message=None):
|
||||
super().log_warning(message, obj)
|
||||
|
||||
def log_failure(self, obj, message):
|
||||
"""
|
||||
Log a failure. Calling this method will automatically mark the report as failed.
|
||||
"""
|
||||
self._log(obj, message, level=LogLevelChoices.LOG_FAILURE)
|
||||
self._results[self.active_test]['failure'] += 1
|
||||
self.logger.info(f"Failure | {obj}: {message}")
|
||||
self.failed = True
|
||||
def log_failure(self, obj=None, message=None):
|
||||
super().log_failure(message, obj)
|
||||
|
||||
#
|
||||
# Run methods
|
||||
#
|
||||
|
||||
def run(self, job):
|
||||
"""
|
||||
Run the report and save its results. Each test method will be executed in order.
|
||||
"""
|
||||
self.logger.info(f"Running report")
|
||||
|
||||
# Perform any post-run tasks
|
||||
self.pre_run()
|
||||
|
||||
try:
|
||||
for method_name in self.test_methods:
|
||||
self.active_test = method_name
|
||||
test_method = getattr(self, method_name)
|
||||
test_method()
|
||||
job.data = self._results
|
||||
if self.failed:
|
||||
self.logger.warning("Report failed")
|
||||
job.terminate(status=JobStatusChoices.STATUS_FAILED)
|
||||
else:
|
||||
self.logger.info("Report completed successfully")
|
||||
job.terminate()
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
|
||||
logger.error(f"Exception raised during report execution: {e}")
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
|
||||
|
||||
# Perform any post-run tasks
|
||||
self.post_run()
|
||||
|
||||
def pre_run(self):
|
||||
"""
|
||||
Extend this method to include any tasks which should execute *before* the report is run.
|
||||
"""
|
||||
pass
|
||||
|
||||
def post_run(self):
|
||||
"""
|
||||
Extend this method to include any tasks which should execute *after* the report is run.
|
||||
"""
|
||||
pass
|
||||
# Added in v4.0 to avoid confusion with the log_debug() method provided by BaseScript
|
||||
def log_debug(self, obj=None, message=None):
|
||||
super().log_debug(message, obj)
|
||||
|
||||
@@ -10,11 +10,12 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import classproperty
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.models import Job
|
||||
from extras.api.serializers import ScriptOutputSerializer
|
||||
from extras.choices import LogLevelChoices
|
||||
from extras.models import ScriptModule
|
||||
from extras.signals import clear_events
|
||||
@@ -25,6 +26,8 @@ from utilities.forms import add_blank_choice
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from .context_managers import event_tracking
|
||||
from .forms import ScriptForm
|
||||
from .utils import is_report
|
||||
|
||||
|
||||
__all__ = (
|
||||
'BaseScript',
|
||||
@@ -270,17 +273,28 @@ class BaseScript:
|
||||
pass
|
||||
|
||||
def __init__(self):
|
||||
self.messages = [] # Primary script log
|
||||
self.tests = {} # Mapping of logs for test methods
|
||||
self.output = ''
|
||||
self.failed = False
|
||||
self._current_test = None # Tracks the current test method being run (if any)
|
||||
|
||||
# Initiate the log
|
||||
self.logger = logging.getLogger(f"netbox.scripts.{self.__module__}.{self.__class__.__name__}")
|
||||
self.log = []
|
||||
|
||||
# Declare the placeholder for the current request
|
||||
self.request = None
|
||||
|
||||
# Grab some info about the script
|
||||
self.filename = inspect.getfile(self.__class__)
|
||||
self.source = inspect.getsource(self.__class__)
|
||||
# Compile test methods and initialize results skeleton
|
||||
for method in dir(self):
|
||||
if method.startswith('test_') and callable(getattr(self, method)):
|
||||
self.tests[method] = {
|
||||
LogLevelChoices.LOG_SUCCESS: 0,
|
||||
LogLevelChoices.LOG_INFO: 0,
|
||||
LogLevelChoices.LOG_WARNING: 0,
|
||||
LogLevelChoices.LOG_FAILURE: 0,
|
||||
'log': [],
|
||||
}
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -331,6 +345,14 @@ class BaseScript:
|
||||
def scheduling_enabled(self):
|
||||
return getattr(self.Meta, 'scheduling_enabled', True)
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
return inspect.getfile(self.__class__)
|
||||
|
||||
@property
|
||||
def source(self):
|
||||
return inspect.getsource(self.__class__)
|
||||
|
||||
@classmethod
|
||||
def _get_vars(cls):
|
||||
vars = {}
|
||||
@@ -356,9 +378,28 @@ class BaseScript:
|
||||
return ordered_vars
|
||||
|
||||
def run(self, data, commit):
|
||||
raise NotImplementedError("The script must define a run() method.")
|
||||
"""
|
||||
Override this method with custom script logic.
|
||||
"""
|
||||
|
||||
# Backward compatibility for legacy Reports
|
||||
self.pre_run()
|
||||
self.run_tests()
|
||||
self.post_run()
|
||||
|
||||
def get_job_data(self):
|
||||
"""
|
||||
Return a dictionary of data to attach to the script's Job.
|
||||
"""
|
||||
return {
|
||||
'log': self.messages,
|
||||
'output': self.output,
|
||||
'tests': self.tests,
|
||||
}
|
||||
|
||||
#
|
||||
# Form rendering
|
||||
#
|
||||
|
||||
def get_fieldsets(self):
|
||||
fieldsets = []
|
||||
@@ -397,29 +438,66 @@ class BaseScript:
|
||||
|
||||
return form
|
||||
|
||||
#
|
||||
# Logging
|
||||
#
|
||||
|
||||
def log_debug(self, message):
|
||||
self.logger.log(logging.DEBUG, message)
|
||||
self.log.append((LogLevelChoices.LOG_DEFAULT, str(message)))
|
||||
def _log(self, message, obj=None, level=LogLevelChoices.LOG_DEFAULT):
|
||||
"""
|
||||
Log a message. Do not call this method directly; use one of the log_* wrappers below.
|
||||
"""
|
||||
if level not in LogLevelChoices.values():
|
||||
raise ValueError(f"Invalid logging level: {level}")
|
||||
|
||||
def log_success(self, message):
|
||||
self.logger.log(logging.INFO, message) # No syslog equivalent for SUCCESS
|
||||
self.log.append((LogLevelChoices.LOG_SUCCESS, str(message)))
|
||||
# A test method is currently active, so log the message using legacy Report logging
|
||||
if self._current_test:
|
||||
|
||||
def log_info(self, message):
|
||||
self.logger.log(logging.INFO, message)
|
||||
self.log.append((LogLevelChoices.LOG_INFO, str(message)))
|
||||
# TODO: Use a dataclass for test method logs
|
||||
self.tests[self._current_test]['log'].append((
|
||||
timezone.now().isoformat(),
|
||||
level,
|
||||
str(obj) if obj else None,
|
||||
obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
|
||||
str(message),
|
||||
))
|
||||
|
||||
def log_warning(self, message):
|
||||
self.logger.log(logging.WARNING, message)
|
||||
self.log.append((LogLevelChoices.LOG_WARNING, str(message)))
|
||||
# Increment the event counter for this level
|
||||
if level in self.tests[self._current_test]:
|
||||
self.tests[self._current_test][level] += 1
|
||||
|
||||
def log_failure(self, message):
|
||||
self.logger.log(logging.ERROR, message)
|
||||
self.log.append((LogLevelChoices.LOG_FAILURE, str(message)))
|
||||
elif message:
|
||||
|
||||
# Record to the script's log
|
||||
self.messages.append({
|
||||
'time': timezone.now().isoformat(),
|
||||
'status': level,
|
||||
'message': str(message),
|
||||
})
|
||||
|
||||
# Record to the system log
|
||||
if obj:
|
||||
message = f"{obj}: {message}"
|
||||
self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], message)
|
||||
|
||||
def log_debug(self, message, obj=None):
|
||||
self._log(message, obj, level=LogLevelChoices.LOG_DEBUG)
|
||||
|
||||
def log_success(self, message, obj=None):
|
||||
self._log(message, obj, level=LogLevelChoices.LOG_SUCCESS)
|
||||
|
||||
def log_info(self, message, obj=None):
|
||||
self._log(message, obj, level=LogLevelChoices.LOG_INFO)
|
||||
|
||||
def log_warning(self, message, obj=None):
|
||||
self._log(message, obj, level=LogLevelChoices.LOG_WARNING)
|
||||
|
||||
def log_failure(self, message, obj=None):
|
||||
self._log(message, obj, level=LogLevelChoices.LOG_FAILURE)
|
||||
self.failed = True
|
||||
|
||||
#
|
||||
# Convenience functions
|
||||
#
|
||||
|
||||
def load_yaml(self, filename):
|
||||
"""
|
||||
@@ -446,6 +524,39 @@ class BaseScript:
|
||||
|
||||
return data
|
||||
|
||||
#
|
||||
# Legacy Report functionality
|
||||
#
|
||||
|
||||
def run_tests(self):
|
||||
"""
|
||||
Run the report and save its results. Each test method will be executed in order.
|
||||
"""
|
||||
self.logger.info(f"Running report")
|
||||
|
||||
try:
|
||||
for test_name in self.tests:
|
||||
self._current_test = test_name
|
||||
test_method = getattr(self, test_name)
|
||||
test_method()
|
||||
self._current_test = None
|
||||
except Exception as e:
|
||||
self._current_test = None
|
||||
self.post_run()
|
||||
raise e
|
||||
|
||||
def pre_run(self):
|
||||
"""
|
||||
Legacy method for operations performed immediately prior to running a Report.
|
||||
"""
|
||||
pass
|
||||
|
||||
def post_run(self):
|
||||
"""
|
||||
Legacy method for operations performed immediately after running a Report.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Script(BaseScript):
|
||||
"""
|
||||
@@ -500,7 +611,16 @@ def run_script(data, job, request=None, commit=True, **kwargs):
|
||||
# Add the current request as a property of the script
|
||||
script.request = request
|
||||
|
||||
def _run_script():
|
||||
def set_job_data(script):
|
||||
job.data = {
|
||||
'log': script.messages,
|
||||
'output': script.output,
|
||||
'tests': script.tests,
|
||||
}
|
||||
|
||||
return job
|
||||
|
||||
def _run_script(job):
|
||||
"""
|
||||
Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
|
||||
the event_tracking context manager (which is bypassed if commit == False).
|
||||
@@ -508,25 +628,39 @@ def run_script(data, job, request=None, commit=True, **kwargs):
|
||||
try:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
script.output = script.run(data=data, commit=commit)
|
||||
script.output = script.run(data, commit)
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
script.log_info(message=_("Database changes have been reverted automatically."))
|
||||
if request:
|
||||
clear_events.send(request)
|
||||
job.data = ScriptOutputSerializer(script).data
|
||||
job.terminate()
|
||||
|
||||
job.data = script.get_job_data()
|
||||
if script.failed:
|
||||
logger.warning(f"Script failed")
|
||||
job.terminate(status=JobStatusChoices.STATUS_FAILED)
|
||||
else:
|
||||
job.terminate()
|
||||
|
||||
except Exception as e:
|
||||
if type(e) is AbortScript:
|
||||
script.log_failure(f"Script aborted with error: {e}")
|
||||
msg = _("Script aborted with error: ") + str(e)
|
||||
if is_report(type(script)):
|
||||
script.log_failure(message=msg)
|
||||
else:
|
||||
script.log_failure(msg)
|
||||
|
||||
logger.error(f"Script aborted with error: {e}")
|
||||
else:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```")
|
||||
script.log_failure(
|
||||
message=_("An exception occurred: ") + f"`{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
|
||||
)
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
job.data = ScriptOutputSerializer(script).data
|
||||
script.log_info(message=_("Database changes have been reverted due to error."))
|
||||
|
||||
job.data = script.get_job_data()
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
|
||||
if request:
|
||||
clear_events.send(request)
|
||||
@@ -537,9 +671,9 @@ def run_script(data, job, request=None, commit=True, **kwargs):
|
||||
# change logging, event rules, etc.
|
||||
if commit:
|
||||
with event_tracking(request):
|
||||
_run_script()
|
||||
_run_script(job)
|
||||
else:
|
||||
_run_script()
|
||||
_run_script(job)
|
||||
|
||||
# Schedule the next job if an interval has been set
|
||||
if job.interval:
|
||||
|
||||
@@ -746,37 +746,6 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
ConfigTemplate.objects.bulk_create(config_templates)
|
||||
|
||||
|
||||
class ReportTest(APITestCase):
|
||||
|
||||
class TestReport(Report):
|
||||
|
||||
def test_foo(self):
|
||||
self.log_success(None, "Report completed")
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
ReportModule.objects.create(
|
||||
file_root=ManagedFileRootPathChoices.REPORTS,
|
||||
file_path='/var/tmp/report.py'
|
||||
)
|
||||
|
||||
def get_test_report(self, *args):
|
||||
return ReportModule.objects.first(), self.TestReport()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Monkey-patch the API viewset's _get_report() method to return our test Report above
|
||||
from extras.api.views import ReportViewSet
|
||||
ReportViewSet._get_report = self.get_test_report
|
||||
|
||||
def test_get_report(self):
|
||||
url = reverse('extras-api:report-detail', kwargs={'pk': None})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.TestReport.__name__)
|
||||
|
||||
|
||||
class ScriptTest(APITestCase):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
@@ -116,15 +116,6 @@ urlpatterns = [
|
||||
path('dashboard/widgets/<uuid:id>/configure/', views.DashboardWidgetConfigView.as_view(), name='dashboardwidget_config'),
|
||||
path('dashboard/widgets/<uuid:id>/delete/', views.DashboardWidgetDeleteView.as_view(), name='dashboardwidget_delete'),
|
||||
|
||||
# Reports
|
||||
path('reports/', views.ReportListView.as_view(), name='report_list'),
|
||||
path('reports/add/', views.ReportModuleCreateView.as_view(), name='reportmodule_add'),
|
||||
path('reports/results/<int:job_pk>/', views.ReportResultView.as_view(), name='report_result'),
|
||||
path('reports/<int:pk>/', include(get_model_urls('extras', 'reportmodule'))),
|
||||
path('reports/<str:module>/<str:name>/', views.ReportView.as_view(), name='report'),
|
||||
path('reports/<str:module>/<str:name>/source/', views.ReportSourceView.as_view(), name='report_source'),
|
||||
path('reports/<str:module>/<str:name>/jobs/', views.ReportJobsView.as_view(), name='report_jobs'),
|
||||
|
||||
# Scripts
|
||||
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
|
||||
path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
|
||||
|
||||
@@ -49,11 +49,12 @@ def register_features(model, features):
|
||||
|
||||
def is_script(obj):
|
||||
"""
|
||||
Returns True if the object is a Script.
|
||||
Returns True if the object is a Script or Report.
|
||||
"""
|
||||
from .reports import Report
|
||||
from .scripts import Script
|
||||
try:
|
||||
return issubclass(obj, Script) and obj != Script
|
||||
return (issubclass(obj, Report) and obj != Report) or (issubclass(obj, Script) and obj != Script)
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import View
|
||||
|
||||
from core.choices import JobStatusChoices, ManagedFileRootPathChoices
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.forms import ManagedFileForm
|
||||
from core.models import Job
|
||||
from core.tables import JobTable
|
||||
@@ -24,9 +24,7 @@ from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
from .forms.reports import ReportForm
|
||||
from .models import *
|
||||
from .reports import run_report
|
||||
from .scripts import run_script
|
||||
|
||||
|
||||
@@ -1006,183 +1004,6 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
|
||||
return redirect(reverse('home'))
|
||||
|
||||
|
||||
#
|
||||
# Reports
|
||||
#
|
||||
|
||||
@register_model_view(ReportModule, 'edit')
|
||||
class ReportModuleCreateView(generic.ObjectEditView):
|
||||
queryset = ReportModule.objects.all()
|
||||
form = ManagedFileForm
|
||||
|
||||
def alter_object(self, obj, *args, **kwargs):
|
||||
obj.file_root = ManagedFileRootPathChoices.REPORTS
|
||||
return obj
|
||||
|
||||
|
||||
@register_model_view(ReportModule, 'delete')
|
||||
class ReportModuleDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ReportModule.objects.all()
|
||||
default_return_url = 'extras:report_list'
|
||||
|
||||
|
||||
class ReportListView(ContentTypePermissionRequiredMixin, View):
|
||||
"""
|
||||
Retrieve all the available reports from disk and the recorded Job (if any) for each.
|
||||
"""
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request):
|
||||
report_modules = ReportModule.objects.restrict(request.user)
|
||||
|
||||
return render(request, 'extras/report_list.html', {
|
||||
'model': ReportModule,
|
||||
'report_modules': report_modules,
|
||||
})
|
||||
|
||||
|
||||
def get_report_module(module, request):
|
||||
return get_object_or_404(ReportModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
|
||||
|
||||
|
||||
class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
"""
|
||||
Display a single Report and its associated Job (if any).
|
||||
"""
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
jobs = module.get_jobs(report.class_name)
|
||||
|
||||
report.result = jobs.filter(
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).first()
|
||||
|
||||
return render(request, 'extras/report.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'report': report,
|
||||
'form': ReportForm(scheduling_enabled=report.scheduling_enabled),
|
||||
})
|
||||
|
||||
def post(self, request, module, name):
|
||||
if not request.user.has_perm('extras.run_report'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
jobs = module.get_jobs(report.class_name)
|
||||
form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled)
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
# Allow execution only if RQ worker process is running
|
||||
if not get_workers_for_queue('default'):
|
||||
messages.error(request, "Unable to run report: RQ worker process not running.")
|
||||
return render(request, 'extras/report.html', {
|
||||
'job_count': jobs.count(),
|
||||
'report': report,
|
||||
})
|
||||
|
||||
# Run the Report. A new Job is created.
|
||||
job = Job.enqueue(
|
||||
run_report,
|
||||
instance=module,
|
||||
name=report.class_name,
|
||||
user=request.user,
|
||||
schedule_at=form.cleaned_data.get('schedule_at'),
|
||||
interval=form.cleaned_data.get('interval'),
|
||||
job_timeout=report.job_timeout
|
||||
)
|
||||
|
||||
return redirect('extras:report_result', job_pk=job.pk)
|
||||
|
||||
return render(request, 'extras/report.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'report': report,
|
||||
'form': form,
|
||||
})
|
||||
|
||||
|
||||
class ReportSourceView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
jobs = module.get_jobs(report.class_name)
|
||||
|
||||
return render(request, 'extras/report/source.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'report': report,
|
||||
'tab': 'source',
|
||||
})
|
||||
|
||||
|
||||
class ReportJobsView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
jobs = module.get_jobs(report.class_name)
|
||||
|
||||
jobs_table = JobTable(
|
||||
data=jobs,
|
||||
orderable=False,
|
||||
user=request.user
|
||||
)
|
||||
jobs_table.configure(request)
|
||||
|
||||
return render(request, 'extras/report/jobs.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'report': report,
|
||||
'table': jobs_table,
|
||||
'tab': 'jobs',
|
||||
})
|
||||
|
||||
|
||||
class ReportResultView(ContentTypePermissionRequiredMixin, View):
|
||||
"""
|
||||
Display a Job pertaining to the execution of a Report.
|
||||
"""
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request, job_pk):
|
||||
object_type = ContentType.objects.get_by_natural_key(app_label='extras', model='reportmodule')
|
||||
job = get_object_or_404(Job.objects.all(), pk=job_pk, object_type=object_type)
|
||||
|
||||
module = job.object
|
||||
report = module.reports[job.name]
|
||||
|
||||
# If this is an HTMX request, return only the result HTML
|
||||
if request.htmx:
|
||||
response = render(request, 'extras/htmx/report_result.html', {
|
||||
'report': report,
|
||||
'job': job,
|
||||
})
|
||||
if job.completed or not job.started:
|
||||
response.status_code = 286
|
||||
return response
|
||||
|
||||
return render(request, 'extras/report_result.html', {
|
||||
'report': report,
|
||||
'job': job,
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Scripts
|
||||
#
|
||||
@@ -1332,20 +1153,28 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View):
|
||||
module = job.object
|
||||
script = module.scripts[job.name]()
|
||||
|
||||
context = {
|
||||
'script': script,
|
||||
'job': job,
|
||||
}
|
||||
if job.data and 'log' in job.data:
|
||||
# Script
|
||||
context['tests'] = job.data.get('tests', {})
|
||||
elif job.data:
|
||||
# Legacy Report
|
||||
context['tests'] = {
|
||||
name: data for name, data in job.data.items()
|
||||
if name.startswith('test_')
|
||||
}
|
||||
|
||||
# If this is an HTMX request, return only the result HTML
|
||||
if request.htmx:
|
||||
response = render(request, 'extras/htmx/script_result.html', {
|
||||
'script': script,
|
||||
'job': job,
|
||||
})
|
||||
response = render(request, 'extras/htmx/script_result.html', context)
|
||||
if job.completed or not job.started:
|
||||
response.status_code = 286
|
||||
return response
|
||||
|
||||
return render(request, 'extras/script_result.html', {
|
||||
'script': script,
|
||||
'job': job,
|
||||
})
|
||||
return render(request, 'extras/script_result.html', context)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -317,14 +317,8 @@ CUSTOMIZATION_MENU = Menu(
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
label=_('Reports & Scripts'),
|
||||
label=_('Scripts'),
|
||||
items=(
|
||||
MenuItem(
|
||||
link='extras:report_list',
|
||||
link_text=_('Reports'),
|
||||
permissions=['extras.view_report'],
|
||||
buttons=get_model_buttons('extras', "reportmodule", actions=['add'])
|
||||
),
|
||||
MenuItem(
|
||||
link='extras:script_list',
|
||||
link_text=_('Scripts'),
|
||||
|
||||
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
23
netbox/project-static/dist/netbox.js
vendored
23
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/netbox.js.map
vendored
4
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
30
netbox/project-static/package-lock.json
generated
30
netbox/project-static/package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"query-string": "^7.1.1",
|
||||
"sass": "^1.55.0",
|
||||
"slim-select": "^1.27.1",
|
||||
"tom-select": "^2.3.1",
|
||||
"typeface-inter": "^3.18.1",
|
||||
"typeface-roboto-mono": "^1.1.13"
|
||||
},
|
||||
@@ -225,6 +226,19 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@orchidjs/sifter": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@orchidjs/sifter/-/sifter-1.0.3.tgz",
|
||||
"integrity": "sha512-zCZbwKegHytfsPm8Amcfh7v/4vHqTAaOu6xFswBYcn8nznBOuseu6COB2ON7ez0tFV0mKL0nRNnCiZZA+lU9/g==",
|
||||
"dependencies": {
|
||||
"@orchidjs/unicode-variants": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@orchidjs/unicode-variants": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz",
|
||||
"integrity": "sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ=="
|
||||
},
|
||||
"node_modules/@pkgr/utils": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz",
|
||||
@@ -3888,6 +3902,22 @@
|
||||
"integrity": "sha1-bkWxJj8gF/oKzH2J14sVuL932jI=",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tom-select": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tom-select/-/tom-select-2.3.1.tgz",
|
||||
"integrity": "sha512-QS4vnOcB6StNGqX4sGboGXL2fkhBF2gIBB+8Hwv30FZXYPn0CyYO8kkdATRvwfCTThxiR4WcXwKJZ3cOmtI9eg==",
|
||||
"dependencies": {
|
||||
"@orchidjs/sifter": "^1.0.3",
|
||||
"@orchidjs/unicode-variants": "^1.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tom-select"
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig-paths": {
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz",
|
||||
|
||||
@@ -31,16 +31,16 @@
|
||||
"gridstack": "^7.2.3",
|
||||
"html-entities": "^2.3.3",
|
||||
"htmx.org": "^1.8.0",
|
||||
"just-debounce-it": "^3.1.1",
|
||||
"query-string": "^7.1.1",
|
||||
"sass": "^1.55.0",
|
||||
"slim-select": "^1.27.1",
|
||||
"tom-select": "^2.3.1",
|
||||
"typeface-inter": "^3.18.1",
|
||||
"typeface-roboto-mono": "^1.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@types/node": "^20.11.16",
|
||||
"@typescript-eslint/eslint-plugin": "^5.39.0",
|
||||
"@typescript-eslint/parser": "^5.39.0",
|
||||
"esbuild": "^0.13.15",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getElements, isTruthy } from './util';
|
||||
import { initButtons } from './buttons';
|
||||
import { initSelect } from './select';
|
||||
import { initSelects } from './select';
|
||||
import { initObjectSelector } from './objectSelector';
|
||||
import { initBootstrap } from './bs';
|
||||
|
||||
function initDepedencies(): void {
|
||||
for (const init of [initButtons, initSelect, initObjectSelector, initBootstrap]) {
|
||||
for (const init of [initButtons, initSelects, initObjectSelector, initBootstrap]) {
|
||||
init();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '@popperjs/core';
|
||||
import 'bootstrap';
|
||||
import 'htmx.org';
|
||||
import 'tom-select';
|
||||
import './netbox';
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { isTruthy, getElements } from './util';
|
||||
|
||||
/**
|
||||
* Allow any element to be made "clickable" with the use of the `data-href` attribute.
|
||||
*/
|
||||
export function initLinks(): void {
|
||||
for (const link of getElements('*[data-href]')) {
|
||||
const href = link.getAttribute('data-href');
|
||||
if (isTruthy(href)) {
|
||||
link.addEventListener('click', () => {
|
||||
window.location.assign(href);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { initForms } from './forms';
|
||||
import { initBootstrap } from './bs';
|
||||
import { initQuickSearch } from './search';
|
||||
import { initSelect } from './select';
|
||||
import { initSelects } from './select';
|
||||
import { initButtons } from './buttons';
|
||||
import { initColorMode } from './colorMode';
|
||||
import { initMessages } from './messages';
|
||||
@@ -12,7 +12,6 @@ import { initInterfaceTable } from './tables';
|
||||
import { initSideNav } from './sidenav';
|
||||
import { initDashboard } from './dashboard';
|
||||
import { initRackElevation } from './racks';
|
||||
import { initLinks } from './links';
|
||||
import { initHtmx } from './htmx';
|
||||
|
||||
function initDocument(): void {
|
||||
@@ -22,7 +21,7 @@ function initDocument(): void {
|
||||
initMessages,
|
||||
initForms,
|
||||
initQuickSearch,
|
||||
initSelect,
|
||||
initSelects,
|
||||
initDateSelector,
|
||||
initButtons,
|
||||
initClipboard,
|
||||
@@ -31,7 +30,6 @@ function initDocument(): void {
|
||||
initSideNav,
|
||||
initDashboard,
|
||||
initRackElevation,
|
||||
initLinks,
|
||||
initHtmx,
|
||||
]) {
|
||||
init();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
import { getElements } from '../../util';
|
||||
import { APISelect } from './apiSelect';
|
||||
|
||||
export function initApiSelect(): void {
|
||||
for (const select of getElements<HTMLSelectElement>('.netbox-api-select:not([data-ssid])')) {
|
||||
new APISelect(select);
|
||||
}
|
||||
}
|
||||
|
||||
export type { Trigger } from './types';
|
||||
@@ -1,199 +0,0 @@
|
||||
import type { Stringifiable } from 'query-string';
|
||||
import type { Option, Optgroup } from 'slim-select/dist/data';
|
||||
|
||||
/**
|
||||
* Map of string keys to primitive array values accepted by `query-string`. Keys are used as
|
||||
* URL query parameter keys. Values correspond to query param values, enforced as an array
|
||||
* for easier handling. For example, a mapping of `{ site_id: [1, 2] }` is serialized by
|
||||
* `query-string` as `?site_id=1&site_id=2`. Likewise, `{ site_id: [1] }` is serialized as
|
||||
* `?site_id=1`.
|
||||
*/
|
||||
export type QueryFilter = Map<string, Stringifiable[]>;
|
||||
|
||||
/**
|
||||
* Tracked data for a related field. This is the value of `APISelect.filterFields`.
|
||||
*/
|
||||
export type FilterFieldValue = {
|
||||
/**
|
||||
* Key to use in the query parameter itself.
|
||||
*/
|
||||
queryParam: string;
|
||||
/**
|
||||
* Value to use in the query parameter for the related field.
|
||||
*/
|
||||
queryValue: Stringifiable[];
|
||||
/**
|
||||
* @see `DataFilterFields.includeNull`
|
||||
*/
|
||||
includeNull: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON data structure from `data-dynamic-params` attribute.
|
||||
*/
|
||||
export type DataDynamicParam = {
|
||||
/**
|
||||
* Name of form field to track.
|
||||
*
|
||||
* @example [name="tenant_group"]
|
||||
*/
|
||||
fieldName: string;
|
||||
/**
|
||||
* Query param key.
|
||||
*
|
||||
* @example group_id
|
||||
*/
|
||||
queryParam: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* `queryParams` Map value.
|
||||
*/
|
||||
export type QueryParam = {
|
||||
queryParam: string;
|
||||
queryValue: Stringifiable[];
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON data structure from `data-static-params` attribute.
|
||||
*/
|
||||
export type DataStaticParam = {
|
||||
queryParam: string;
|
||||
queryValue: Stringifiable | Stringifiable[];
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON data passed from Django on the `data-filter-fields` attribute.
|
||||
*/
|
||||
export type DataFilterFields = {
|
||||
/**
|
||||
* Related field form name (`[name="<fieldName>"]`)
|
||||
*
|
||||
* @example tenant_group
|
||||
*/
|
||||
fieldName: string;
|
||||
/**
|
||||
* Key to use in the query parameter itself.
|
||||
*
|
||||
* @example group_id
|
||||
*/
|
||||
queryParam: string;
|
||||
/**
|
||||
* Optional default value. If set, value will be added to the query parameters prior to the
|
||||
* initial API call and will be maintained until the field `fieldName` references (if one exists)
|
||||
* is updated with a new value.
|
||||
*
|
||||
* @example 1
|
||||
*/
|
||||
defaultValue: Nullable<Stringifiable | Stringifiable[]>;
|
||||
/**
|
||||
* Include `null` on queries for the related field. For example, if `true`, `?<fieldName>=null`
|
||||
* will be added to all API queries for this field.
|
||||
*/
|
||||
includeNull: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Map of string keys to primitive values. Used to track variables within URLs from the server. For
|
||||
* example, `/api/$key/thing`. `PathFilter` tracks `$key` as `{ key: '' }` in the map, and when the
|
||||
* value is later known, the value is set — `{ key: 'value' }`, and the URL is transformed to
|
||||
* `/api/value/thing`.
|
||||
*/
|
||||
export type PathFilter = Map<string, Stringifiable>;
|
||||
|
||||
/**
|
||||
* Merge or replace incoming options with current options.
|
||||
*/
|
||||
export type ApplyMethod = 'merge' | 'replace';
|
||||
|
||||
/**
|
||||
* Trigger for which the select instance should fetch its data from the NetBox API.
|
||||
*/
|
||||
export type Trigger =
|
||||
/**
|
||||
* Load data when the select element is opened.
|
||||
*/
|
||||
| 'open'
|
||||
/**
|
||||
* Load data when the element is loaded.
|
||||
*/
|
||||
| 'load'
|
||||
/**
|
||||
* Load data when a parent element is uncollapsed.
|
||||
*/
|
||||
| 'collapse';
|
||||
|
||||
/**
|
||||
* Strict Type Guard to determine if a deserialized value from the `data-filter-fields` attribute
|
||||
* is of type `DataFilterFields`.
|
||||
*
|
||||
* @param value Deserialized value from `data-filter-fields` attribute.
|
||||
*/
|
||||
export function isDataFilterFields(value: unknown): value is DataFilterFields[] {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
if ('fieldName' in item && 'queryParam' in item) {
|
||||
return (
|
||||
typeof (item as DataFilterFields).fieldName === 'string' &&
|
||||
typeof (item as DataFilterFields).queryParam === 'string'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict Type Guard to determine if a deserialized value from the `data-dynamic-params` attribute
|
||||
* is of type `DataDynamicParam[]`.
|
||||
*
|
||||
* @param value Deserialized value from `data-dynamic-params` attribute.
|
||||
*/
|
||||
export function isDataDynamicParams(value: unknown): value is DataDynamicParam[] {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
if ('fieldName' in item && 'queryParam' in item) {
|
||||
return (
|
||||
typeof (item as DataDynamicParam).fieldName === 'string' &&
|
||||
typeof (item as DataDynamicParam).queryParam === 'string'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict Type Guard to determine if a deserialized value from the `data-static-params` attribute
|
||||
* is of type `DataStaticParam[]`.
|
||||
*
|
||||
* @param value Deserialized value from `data-static-params` attribute.
|
||||
*/
|
||||
export function isStaticParams(value: unknown): value is DataStaticParam[] {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
if ('queryParam' in item && 'queryValue' in item) {
|
||||
return (
|
||||
typeof (item as DataStaticParam).queryParam === 'string' &&
|
||||
typeof (item as DataStaticParam).queryValue !== 'undefined'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to determine if a SlimSelect `dataObject` is an `Option`.
|
||||
*
|
||||
* @param data Option or Option Group
|
||||
*/
|
||||
export function isOption(data: Option | Optgroup): data is Option {
|
||||
return !('options' in data);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isTruthy } from '../../util';
|
||||
import { isDataDynamicParams } from './types';
|
||||
import { isDataDynamicParams } from '../types';
|
||||
|
||||
import type { QueryParam } from './types';
|
||||
import type { QueryParam } from '../types';
|
||||
|
||||
/**
|
||||
* Extension of built-in `Map` to add convenience functions.
|
||||
305
netbox/project-static/src/select/classes/dynamicTomSelect.ts
Normal file
305
netbox/project-static/src/select/classes/dynamicTomSelect.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { RecursivePartial, TomInput, TomOption, TomSettings } from 'tom-select/dist/types/types';
|
||||
import { addClasses } from 'tom-select/src/vanilla'
|
||||
import queryString from 'query-string';
|
||||
import TomSelect from 'tom-select';
|
||||
import type { Stringifiable } from 'query-string';
|
||||
import { DynamicParamsMap } from './dynamicParamsMap';
|
||||
|
||||
// Transitional
|
||||
import { QueryFilter, PathFilter } from '../types'
|
||||
import { getElement, replaceAll } from '../../util';
|
||||
|
||||
|
||||
// Extends TomSelect to provide enhanced fetching of options via the REST API
|
||||
export class DynamicTomSelect extends TomSelect {
|
||||
|
||||
public readonly nullOption: Nullable<TomOption> = null;
|
||||
|
||||
// Transitional code from APISelect
|
||||
private readonly queryParams: QueryFilter = new Map();
|
||||
private readonly staticParams: QueryFilter = new Map();
|
||||
private readonly dynamicParams: DynamicParamsMap = new DynamicParamsMap();
|
||||
private readonly pathValues: PathFilter = new Map();
|
||||
|
||||
/**
|
||||
* Overrides
|
||||
*/
|
||||
|
||||
constructor( input_arg: string|TomInput, user_settings: RecursivePartial<TomSettings> ) {
|
||||
super(input_arg, user_settings);
|
||||
|
||||
// Glean the REST API endpoint URL from the <select> element
|
||||
this.api_url = this.input.getAttribute('data-url') as string;
|
||||
|
||||
// Set the null option (if any)
|
||||
const nullOption = this.input.getAttribute('data-null-option');
|
||||
if (nullOption) {
|
||||
let valueField = this.settings.valueField;
|
||||
let labelField = this.settings.labelField;
|
||||
this.nullOption = {}
|
||||
this.nullOption[valueField] = 'null';
|
||||
this.nullOption[labelField] = nullOption;
|
||||
}
|
||||
|
||||
// Populate static query parameters.
|
||||
this.getStaticParams();
|
||||
for (const [key, value] of this.staticParams.entries()) {
|
||||
this.queryParams.set(key, value);
|
||||
}
|
||||
|
||||
// Populate dynamic query parameters
|
||||
this.getDynamicParams();
|
||||
for (const filter of this.dynamicParams.keys()) {
|
||||
this.updateQueryParams(filter);
|
||||
}
|
||||
|
||||
// Path values
|
||||
this.getPathKeys();
|
||||
for (const filter of this.pathValues.keys()) {
|
||||
this.updatePathValues(filter);
|
||||
}
|
||||
|
||||
// Add dependency event listeners.
|
||||
this.addEventListeners();
|
||||
}
|
||||
|
||||
load(value: string) {
|
||||
const self = this;
|
||||
const url = self.getRequestUrl(value);
|
||||
|
||||
// Automatically clear any cached options. (Only options included
|
||||
// in the API response should be present.)
|
||||
self.clearOptions();
|
||||
|
||||
addClasses(self.wrapper, self.settings.loadingClass);
|
||||
self.loading++;
|
||||
|
||||
// Populate the null option (if any) if not searching
|
||||
if (self.nullOption && !value) {
|
||||
self.addOption(self.nullOption);
|
||||
}
|
||||
|
||||
// Make the API request
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
self.loadCallback(json.results, []);
|
||||
}).catch(()=>{
|
||||
self.loadCallback([], []);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom methods
|
||||
*/
|
||||
|
||||
// Formulate and return the complete URL for an API request, including any query parameters.
|
||||
getRequestUrl(search: string): string {
|
||||
let url = this.api_url;
|
||||
|
||||
// Create new URL query parameters based on the current state of `queryParams` and create an
|
||||
// updated API query URL.
|
||||
const query = {} as Dict<Stringifiable[]>;
|
||||
for (const [key, value] of this.queryParams.entries()) {
|
||||
query[key] = value;
|
||||
}
|
||||
|
||||
// Replace any variables in the URL with values from `pathValues` if set.
|
||||
for (const [key, value] of this.pathValues.entries()) {
|
||||
for (const result of this.api_url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
|
||||
if (value) {
|
||||
url = replaceAll(url, result[1], value.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append the search query, if any
|
||||
if (search) {
|
||||
query['q'] = [search];
|
||||
}
|
||||
|
||||
// Add standard parameters
|
||||
query['brief'] = [true];
|
||||
query['limit'] = [this.settings.maxOptions];
|
||||
|
||||
return queryString.stringifyUrl({ url, query });
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitional methods
|
||||
*/
|
||||
|
||||
// Determine if this instance's options should be filtered by static values passed from the
|
||||
// server. Looks for the DOM attribute `data-static-params`, the value of which is a JSON
|
||||
// array of objects containing key/value pairs to add to `this.staticParams`.
|
||||
private getStaticParams(): void {
|
||||
const serialized = this.input.getAttribute('data-static-params');
|
||||
|
||||
try {
|
||||
if (serialized) {
|
||||
const deserialized = JSON.parse(serialized);
|
||||
if (deserialized) {
|
||||
for (const { queryParam, queryValue } of deserialized) {
|
||||
if (Array.isArray(queryValue)) {
|
||||
this.staticParams.set(queryParam, queryValue);
|
||||
} else {
|
||||
this.staticParams.set(queryParam, [queryValue]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.group(`Unable to determine static query parameters for select field '${this.name}'`);
|
||||
console.warn(err);
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if this instances' options should be filtered by the value of another select
|
||||
// element. Looks for the DOM attribute `data-dynamic-params`, the value of which is a JSON
|
||||
// array of objects containing information about how to handle the related field.
|
||||
private getDynamicParams(): void {
|
||||
const serialized = this.input.getAttribute('data-dynamic-params');
|
||||
try {
|
||||
this.dynamicParams.addFromJson(serialized);
|
||||
} catch (err) {
|
||||
console.group(`Unable to determine dynamic query parameters for select field '${this.name}'`);
|
||||
console.warn(err);
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Parse the `data-url` attribute to add any variables to `pathValues` as keys with empty
|
||||
// values. As those keys' corresponding form fields' values change, `pathValues` will be
|
||||
// updated to reflect the new value.
|
||||
private getPathKeys() {
|
||||
for (const result of this.api_url.matchAll(new RegExp(`{{(.+)}}`, 'g'))) {
|
||||
this.pathValues.set(result[1], '');
|
||||
}
|
||||
}
|
||||
|
||||
// Update an element's API URL based on the value of another element on which this element
|
||||
// relies.
|
||||
private updateQueryParams(fieldName: string): void {
|
||||
// Find the element dependency.
|
||||
const element = document.querySelector<HTMLSelectElement>(`[name="${fieldName}"]`);
|
||||
if (element !== null) {
|
||||
// Initialize the element value as an array, in case there are multiple values.
|
||||
let elementValue = [] as Stringifiable[];
|
||||
|
||||
if (element.multiple) {
|
||||
// If this is a multi-select (form filters, tags, etc.), use all selected options as the value.
|
||||
elementValue = Array.from(element.options)
|
||||
.filter(o => o.selected)
|
||||
.map(o => o.value);
|
||||
} else if (element.value !== '') {
|
||||
// If this is single-select (most fields), use the element's value. This seemingly
|
||||
// redundant/verbose check is mainly for performance, so we're not running the above three
|
||||
// functions (`Array.from()`, `Array.filter()`, `Array.map()`) every time every select
|
||||
// field's value changes.
|
||||
elementValue = [element.value];
|
||||
}
|
||||
|
||||
if (elementValue.length > 0) {
|
||||
// If the field has a value, add it to the map.
|
||||
this.dynamicParams.updateValue(fieldName, elementValue);
|
||||
// Get the updated value.
|
||||
const current = this.dynamicParams.get(fieldName);
|
||||
|
||||
if (typeof current !== 'undefined') {
|
||||
const { queryParam, queryValue } = current;
|
||||
let value = [] as Stringifiable[];
|
||||
|
||||
if (this.staticParams.has(queryParam)) {
|
||||
// If the field is defined in `staticParams`, we should merge the dynamic value with
|
||||
// the static value.
|
||||
const staticValue = this.staticParams.get(queryParam);
|
||||
if (typeof staticValue !== 'undefined') {
|
||||
value = [...staticValue, ...queryValue];
|
||||
}
|
||||
} else {
|
||||
// If the field is _not_ defined in `staticParams`, we should replace the current value
|
||||
// with the new dynamic value.
|
||||
value = queryValue;
|
||||
}
|
||||
if (value.length > 0) {
|
||||
this.queryParams.set(queryParam, value);
|
||||
} else {
|
||||
this.queryParams.delete(queryParam);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
|
||||
const queryParam = this.dynamicParams.queryParam(fieldName);
|
||||
if (queryParam !== null) {
|
||||
this.queryParams.delete(queryParam);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update `pathValues` based on the form value of another element.
|
||||
private updatePathValues(id: string): void {
|
||||
const key = replaceAll(id, /^id_/i, '');
|
||||
const element = getElement<HTMLSelectElement>(`id_${key}`);
|
||||
if (element !== null) {
|
||||
// If this element's URL contains variable tags ({{), replace the tag with the dependency's
|
||||
// value. For example, if the dependency is the `rack` field, and the `rack` field's value
|
||||
// is `1`, this element's URL would change from `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`.
|
||||
const hasReplacement =
|
||||
this.api_url.includes(`{{`) && Boolean(this.api_url.match(new RegExp(`({{(${id})}})`, 'g')));
|
||||
|
||||
if (hasReplacement) {
|
||||
if (element.value) {
|
||||
// If the field has a value, add it to the map.
|
||||
this.pathValues.set(id, element.value);
|
||||
} else {
|
||||
// Otherwise, reset the value.
|
||||
this.pathValues.set(id, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Events
|
||||
*/
|
||||
|
||||
// Add event listeners to this element and its dependencies so that when dependencies change
|
||||
//this element's options are updated.
|
||||
private addEventListeners(): void {
|
||||
// Create a unique iterator of all possible form fields which, when changed, should cause this
|
||||
// element to update its API query.
|
||||
const dependencies = new Set([...this.dynamicParams.keys(), ...this.pathValues.keys()]);
|
||||
|
||||
for (const dep of dependencies) {
|
||||
const filterElement = document.querySelector(`[name="${dep}"]`);
|
||||
if (filterElement !== null) {
|
||||
// Subscribe to dependency changes.
|
||||
filterElement.addEventListener('change', event => this.handleEvent(event));
|
||||
}
|
||||
// Subscribe to changes dispatched by this state manager.
|
||||
this.input.addEventListener(`netbox.select.onload.${dep}`, event => this.handleEvent(event));
|
||||
}
|
||||
}
|
||||
|
||||
// Event handler to be dispatched any time a dependency's value changes. For example, when the
|
||||
// value of `tenant_group` changes, `handleEvent` is called to get the current value of
|
||||
// `tenant_group` and update the query parameters and API query URL for the `tenant` field.
|
||||
private handleEvent(event: Event): void {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
|
||||
// Update the element's URL after any changes to a dependency.
|
||||
this.updateQueryParams(target.name);
|
||||
this.updatePathValues(target.name);
|
||||
|
||||
// Clear any previous selection(s) as the parent filter has changed
|
||||
this.clear();
|
||||
|
||||
// Load new data.
|
||||
this.load(this.lastValue);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import SlimSelect from 'slim-select';
|
||||
import { readableColor } from 'color2k';
|
||||
import { getElements } from '../util';
|
||||
|
||||
import type { Option } from 'slim-select/dist/data';
|
||||
|
||||
/**
|
||||
* Determine if the option has a valid value (i.e., is not the placeholder).
|
||||
*/
|
||||
function canChangeColor(option: Option | HTMLOptionElement): boolean {
|
||||
return typeof option.value === 'string' && option.value !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Style the container element based on the selected option value.
|
||||
*/
|
||||
function styleContainer(
|
||||
instance: InstanceType<typeof SlimSelect>,
|
||||
option: Option | HTMLOptionElement,
|
||||
): void {
|
||||
if (instance.slim.singleSelected !== null) {
|
||||
if (canChangeColor(option)) {
|
||||
// Get the background color from the selected option's value.
|
||||
const bg = `#${option.value}`;
|
||||
// Determine an accessible foreground color based on the background color.
|
||||
const fg = readableColor(bg);
|
||||
|
||||
// Set the container's style attributes.
|
||||
instance.slim.singleSelected.container.style.backgroundColor = bg;
|
||||
instance.slim.singleSelected.container.style.color = fg;
|
||||
} else {
|
||||
// If the color cannot be set (i.e., the placeholder), remove any inline styles.
|
||||
instance.slim.singleSelected.container.removeAttribute('style');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize color selection widget. Dynamically change the style of the select container to match
|
||||
* the selected option.
|
||||
*/
|
||||
export function initColorSelect(): void {
|
||||
for (const select of getElements<HTMLSelectElement>(
|
||||
'select.netbox-color-select:not([data-ssid])',
|
||||
)) {
|
||||
for (const option of select.options) {
|
||||
if (canChangeColor(option)) {
|
||||
// Get the background color from the option's value.
|
||||
const bg = `#${option.value}`;
|
||||
// Determine an accessible foreground color based on the background color.
|
||||
const fg = readableColor(bg);
|
||||
|
||||
// Set the option's style attributes.
|
||||
option.style.backgroundColor = bg;
|
||||
option.style.color = fg;
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new SlimSelect({
|
||||
select,
|
||||
allowDeselect: true,
|
||||
// Inherit the calculated color on the deselect icon.
|
||||
deselectLabel: `<i class="mdi mdi-close-circle" style="color: currentColor;"></i>`,
|
||||
});
|
||||
|
||||
// Style the select container to match any pre-selectd options.
|
||||
for (const option of instance.data.data) {
|
||||
if ('selected' in option && option.selected) {
|
||||
styleContainer(instance, option);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't inherit the select element's classes.
|
||||
for (const className of select.classList) {
|
||||
instance.slim.container.classList.remove(className);
|
||||
}
|
||||
|
||||
// Change the SlimSelect container's style based on the selected option.
|
||||
instance.onChange = option => styleContainer(instance, option);
|
||||
}
|
||||
}
|
||||
9
netbox/project-static/src/select/config.ts
Normal file
9
netbox/project-static/src/select/config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const config = {
|
||||
plugins: {
|
||||
// Provides the "clear" button on the widget
|
||||
clear_button: {
|
||||
html: (data: Dict) =>
|
||||
`<i class="mdi mdi-close-circle ${data.className}" title="${data.title}"></i>`,
|
||||
},
|
||||
},
|
||||
};
|
||||
51
netbox/project-static/src/select/dynamic.ts
Normal file
51
netbox/project-static/src/select/dynamic.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { TomOption } from 'tom-select/src/types';
|
||||
import { escape_html } from 'tom-select/src/utils';
|
||||
import { DynamicTomSelect } from './classes/dynamicTomSelect';
|
||||
import { config } from './config';
|
||||
import { getElements } from '../util';
|
||||
|
||||
const VALUE_FIELD = 'id';
|
||||
const LABEL_FIELD = 'display';
|
||||
const MAX_OPTIONS = 100;
|
||||
|
||||
// Render the HTML for a dropdown option
|
||||
function renderOption(data: TomOption, escape: typeof escape_html) {
|
||||
// If the option has a `_depth` property, indent its label
|
||||
if (typeof data._depth === 'number' && data._depth > 0) {
|
||||
return `<div>${'─'.repeat(data._depth)} ${escape(data[LABEL_FIELD])}</div>`;
|
||||
}
|
||||
|
||||
return `<div>${escape(data[LABEL_FIELD])}</div>`;
|
||||
}
|
||||
|
||||
// Initialize <select> elements which are populated via a REST API call
|
||||
export function initDynamicSelects(): void {
|
||||
for (const select of getElements<HTMLSelectElement>('select.api-select')) {
|
||||
new DynamicTomSelect(select, {
|
||||
...config,
|
||||
valueField: VALUE_FIELD,
|
||||
labelField: LABEL_FIELD,
|
||||
maxOptions: MAX_OPTIONS,
|
||||
|
||||
// Disable local search (search is performed on the backend)
|
||||
searchField: [],
|
||||
|
||||
// Reference the disabled-indicator attr on the <select> element to determine
|
||||
// the name of the attribute which indicates whether an option should be disabled
|
||||
disabledField: select.getAttribute('disabled-indicator') || undefined,
|
||||
|
||||
// Load options from API immediately on focus
|
||||
preload: 'focus',
|
||||
|
||||
// Define custom rendering functions
|
||||
render: {
|
||||
option: renderOption,
|
||||
},
|
||||
|
||||
// By default, load() will be called only if query.length > 0
|
||||
shouldLoad: function (): boolean {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { initApiSelect } from './api';
|
||||
import { initColorSelect } from './color';
|
||||
import { initStaticSelect } from './static';
|
||||
import { initColorSelects, initStaticSelects } from './static';
|
||||
import { initDynamicSelects } from './dynamic';
|
||||
|
||||
export function initSelect(): void {
|
||||
for (const func of [initApiSelect, initColorSelect, initStaticSelect]) {
|
||||
func();
|
||||
}
|
||||
export function initSelects(): void {
|
||||
initStaticSelects();
|
||||
initDynamicSelects();
|
||||
initColorSelects();
|
||||
}
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import SlimSelect from 'slim-select';
|
||||
import { TomOption } from 'tom-select/src/types';
|
||||
import TomSelect from 'tom-select';
|
||||
import { escape_html } from 'tom-select/src/utils';
|
||||
import { config } from './config';
|
||||
import { getElements } from '../util';
|
||||
|
||||
export function initStaticSelect(): void {
|
||||
for (const select of getElements<HTMLSelectElement>('.netbox-static-select:not([data-ssid])')) {
|
||||
if (select !== null) {
|
||||
const label = document.querySelector(`label[for="${select.id}"]`) as HTMLLabelElement;
|
||||
|
||||
let placeholder;
|
||||
if (label !== null) {
|
||||
placeholder = `Select ${label.innerText.trim()}`;
|
||||
}
|
||||
|
||||
const instance = new SlimSelect({
|
||||
select,
|
||||
allowDeselect: true,
|
||||
deselectLabel: `<i class="mdi mdi-close-circle"></i>`,
|
||||
placeholder,
|
||||
});
|
||||
|
||||
// Don't copy classes from select element to SlimSelect instance.
|
||||
for (const className of select.classList) {
|
||||
instance.slim.container.classList.remove(className);
|
||||
}
|
||||
}
|
||||
// Initialize <select> elements with statically-defined options
|
||||
export function initStaticSelects(): void {
|
||||
for (const select of getElements<HTMLSelectElement>(
|
||||
'select:not(.api-select):not(.color-select)',
|
||||
)) {
|
||||
new TomSelect(select, {
|
||||
...config,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize color selection fields
|
||||
export function initColorSelects(): void {
|
||||
for (const select of getElements<HTMLSelectElement>('select.color-select')) {
|
||||
new TomSelect(select, {
|
||||
...config,
|
||||
render: {
|
||||
option: function (item: TomOption, escape: typeof escape_html) {
|
||||
return `<div style="background-color: #${escape(item.value)}">${escape(item.text)}</div>`;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
66
netbox/project-static/src/select/types.ts
Normal file
66
netbox/project-static/src/select/types.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Stringifiable } from 'query-string';
|
||||
|
||||
/**
|
||||
* Map of string keys to primitive array values accepted by `query-string`. Keys are used as
|
||||
* URL query parameter keys. Values correspond to query param values, enforced as an array
|
||||
* for easier handling. For example, a mapping of `{ site_id: [1, 2] }` is serialized by
|
||||
* `query-string` as `?site_id=1&site_id=2`. Likewise, `{ site_id: [1] }` is serialized as
|
||||
* `?site_id=1`.
|
||||
*/
|
||||
export type QueryFilter = Map<string, Stringifiable[]>;
|
||||
|
||||
/**
|
||||
* JSON data structure from `data-dynamic-params` attribute.
|
||||
*/
|
||||
export type DataDynamicParam = {
|
||||
/**
|
||||
* Name of form field to track.
|
||||
*
|
||||
* @example [name="tenant_group"]
|
||||
*/
|
||||
fieldName: string;
|
||||
/**
|
||||
* Query param key.
|
||||
*
|
||||
* @example group_id
|
||||
*/
|
||||
queryParam: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* `queryParams` Map value.
|
||||
*/
|
||||
export type QueryParam = {
|
||||
queryParam: string;
|
||||
queryValue: Stringifiable[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Map of string keys to primitive values. Used to track variables within URLs from the server. For
|
||||
* example, `/api/$key/thing`. `PathFilter` tracks `$key` as `{ key: '' }` in the map, and when the
|
||||
* value is later known, the value is set — `{ key: 'value' }`, and the URL is transformed to
|
||||
* `/api/value/thing`.
|
||||
*/
|
||||
export type PathFilter = Map<string, Stringifiable>;
|
||||
|
||||
/**
|
||||
* Strict Type Guard to determine if a deserialized value from the `data-dynamic-params` attribute
|
||||
* is of type `DataDynamicParam[]`.
|
||||
*
|
||||
* @param value Deserialized value from `data-dynamic-params` attribute.
|
||||
*/
|
||||
export function isDataDynamicParams(value: unknown): value is DataDynamicParam[] {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
if ('fieldName' in item && 'queryParam' in item) {
|
||||
return (
|
||||
typeof (item as DataDynamicParam).fieldName === 'string' &&
|
||||
typeof (item as DataDynamicParam).queryParam === 'string'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { Trigger } from './api';
|
||||
|
||||
/**
|
||||
* Determine if an element has the `data-url` attribute set.
|
||||
*/
|
||||
export function hasUrl(el: HTMLSelectElement): el is HTMLSelectElement & { 'data-url': string } {
|
||||
const value = el.getAttribute('data-url');
|
||||
return typeof value === 'string' && value !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an element has the `data-query-param-exclude` attribute set.
|
||||
*/
|
||||
export function hasExclusions(
|
||||
el: HTMLSelectElement,
|
||||
): el is HTMLSelectElement & { 'data-query-param-exclude': string } {
|
||||
const exclude = el.getAttribute('data-query-param-exclude');
|
||||
return typeof exclude === 'string' && exclude !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a trigger value is valid.
|
||||
*/
|
||||
export function isTrigger(value: unknown): value is Trigger {
|
||||
return typeof value === 'string' && ['load', 'open', 'collapse'].includes(value);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
@import 'variables';
|
||||
|
||||
// Tabler
|
||||
// Tabler & vendors
|
||||
@import '../node_modules/@tabler/core/src/scss/_core.scss';
|
||||
@import '../node_modules/@tabler/core/src/scss/vendor/tom-select';
|
||||
|
||||
// Overrides of external libraries
|
||||
@import 'overrides/slim-select';
|
||||
@import 'overrides/tabler';
|
||||
|
||||
// Transitional styling to ease migration of templates from NetBox v3.x
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
// SlimSelect Style Overrides.
|
||||
|
||||
$height: $input-height;
|
||||
$white: $white;
|
||||
$font-color: $input-color;
|
||||
$font-placeholder-color: $input-placeholder-color;
|
||||
$font-disabled-color: $form-select-disabled-color;
|
||||
$primary-color: $primary;
|
||||
$border-color: $form-select-border-color;
|
||||
$search-highlight-color: $yellow;
|
||||
$border-radius: $form-select-border-radius;
|
||||
$spacing-l: $input-padding-x;
|
||||
$spacing-m: $input-padding-x;
|
||||
$spacing-s: $input-padding-x;
|
||||
|
||||
:root {
|
||||
// Light Mode Variables.
|
||||
--nbx-select-content-bg: #{$form-select-bg};
|
||||
--nbx-select-option-selected-bg: #{$gray-300};
|
||||
--nbx-select-option-hover-bg: #{$blue};
|
||||
--nbx-select-option-hover-color: #{$white};
|
||||
--nbx-select-placeholder-color: #{$gray-500};
|
||||
--nbx-select-value-color: #{$white};
|
||||
&[data-netbox-color-mode='dark'] {
|
||||
// Dark Mode Variables.
|
||||
--nbx-select-content-bg: #{$gray-900};
|
||||
--nbx-select-option-selected-bg: #{$gray-500};
|
||||
--nbx-select-option-hover-bg: #{$blue-200};
|
||||
--nbx-select-option-hover-color: #{color-contrast($blue-200)};
|
||||
--nbx-select-placeholder-color: #{$gray-700};
|
||||
--nbx-select-value-color: #{$black};
|
||||
}
|
||||
}
|
||||
|
||||
@import '../node_modules/slim-select/src/slim-select/slimselect';
|
||||
|
||||
.ss-main {
|
||||
color: $form-select-color;
|
||||
|
||||
.ss-single-selected,
|
||||
.ss-multi-selected {
|
||||
padding: $form-select-padding-y $input-padding-x $form-select-padding-y $form-select-padding-x;
|
||||
background-color: $form-select-bg;
|
||||
border: $form-select-border-width solid $input-border-color;
|
||||
&[disabled] {
|
||||
color: $form-select-disabled-color;
|
||||
background-color: $form-select-disabled-bg;
|
||||
border-color: $form-select-disabled-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
div.ss-multi-selected .ss-values .ss-disabled,
|
||||
div.ss-single-selected span.placeholder .ss-disabled {
|
||||
color: var(--nbx-select-placeholder-color);
|
||||
}
|
||||
|
||||
.ss-single-selected {
|
||||
span.ss-arrow {
|
||||
// Inherit the arrow color from the parent (see color selector).
|
||||
span.arrow-down,
|
||||
span.arrow-up {
|
||||
border-color: currentColor;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
// Don't show the depth indicator outside of the menu.
|
||||
.placeholder .depth {
|
||||
display: none;
|
||||
}
|
||||
span.placeholder > *,
|
||||
span.placeholder {
|
||||
line-height: $input-line-height;
|
||||
}
|
||||
}
|
||||
|
||||
.ss-multi-selected {
|
||||
align-items: center;
|
||||
padding-right: $input-padding-x;
|
||||
padding-left: $input-padding-x;
|
||||
|
||||
.ss-values {
|
||||
.ss-disabled {
|
||||
padding: 4px 0;
|
||||
}
|
||||
.ss-value {
|
||||
color: var(--nbx-select-value-color);
|
||||
border-radius: $badge-border-radius;
|
||||
|
||||
// Don't show the depth indicator outside of the menu.
|
||||
.depth {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ss-add {
|
||||
margin: 0 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ss-content {
|
||||
background-color: var(--nbx-select-content-bg);
|
||||
.ss-list {
|
||||
.ss-option {
|
||||
&.ss-option-selected {
|
||||
color: $body-color;
|
||||
background-color: var(--nbx-select-option-selected-bg);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--nbx-select-option-hover-color);
|
||||
background-color: var(--nbx-select-option-hover-bg);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-right-radius: $form-select-border-radius;
|
||||
border-bottom-left-radius: $form-select-border-radius;
|
||||
}
|
||||
|
||||
&.ss-disabled {
|
||||
background-color: unset;
|
||||
&:hover {
|
||||
color: $form-select-disabled-color;
|
||||
}
|
||||
}
|
||||
|
||||
.depth {
|
||||
// Lighten the dash prefix on nested options.
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
right: 0;
|
||||
width: 4px;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
right: 0;
|
||||
width: 2px;
|
||||
background-color: var(--nbx-sidebar-scroll);
|
||||
}
|
||||
}
|
||||
border-bottom-right-radius: $form-select-border-radius;
|
||||
border-bottom-left-radius: $form-select-border-radius;
|
||||
.ss-search {
|
||||
padding-right: $spacer * 0.5;
|
||||
|
||||
button {
|
||||
margin-left: $spacer * 0.75;
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
color: $input-color;
|
||||
background-color: $form-select-bg;
|
||||
border: $input-border-width solid $input-border-color;
|
||||
&:focus {
|
||||
border-color: $form-select-focus-border-color;
|
||||
outline: 0;
|
||||
@if $enable-shadows {
|
||||
@include box-shadow($form-select-box-shadow, $form-select-focus-box-shadow);
|
||||
} @else {
|
||||
// Avoid using mixin so we can pass custom focus shadow properly
|
||||
box-shadow: $form-select-focus-box-shadow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix slim-select 1.x placeholder styling
|
||||
.ss-main {
|
||||
.ss-single-selected {
|
||||
.placeholder {
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply red border for fields inside a row with .has-errors
|
||||
.has-errors {
|
||||
.ss-single-selected,
|
||||
.ss-multi-selected {
|
||||
border-color: $red;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"moduleResolution": "node",
|
||||
"noUnusedParameters": true,
|
||||
// tom-select v2.3.1 raises several TS6133 errors with noUnusedParameters
|
||||
"noUnusedParameters": false,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"noUnusedLocals": true,
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@esbuild/linux-loong64@0.14.54":
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028"
|
||||
integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==
|
||||
|
||||
"@eslint/eslintrc@^1.3.2":
|
||||
version "1.3.2"
|
||||
resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.2.tgz"
|
||||
@@ -67,7 +72,7 @@
|
||||
"@nodelib/fs.stat" "2.0.5"
|
||||
run-parallel "^1.1.9"
|
||||
|
||||
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
|
||||
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
|
||||
version "2.0.5"
|
||||
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
|
||||
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
|
||||
@@ -80,6 +85,18 @@
|
||||
"@nodelib/fs.scandir" "2.1.5"
|
||||
fastq "^1.6.0"
|
||||
|
||||
"@orchidjs/sifter@^1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.npmjs.org/@orchidjs/sifter/-/sifter-1.0.3.tgz"
|
||||
integrity sha512-zCZbwKegHytfsPm8Amcfh7v/4vHqTAaOu6xFswBYcn8nznBOuseu6COB2ON7ez0tFV0mKL0nRNnCiZZA+lU9/g==
|
||||
dependencies:
|
||||
"@orchidjs/unicode-variants" "^1.0.4"
|
||||
|
||||
"@orchidjs/unicode-variants@^1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.npmjs.org/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz"
|
||||
integrity sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ==
|
||||
|
||||
"@pkgr/utils@^2.3.1":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz"
|
||||
@@ -92,16 +109,11 @@
|
||||
tiny-glob "^0.2.9"
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@popperjs/core@^2.11.8":
|
||||
"@popperjs/core@^2.11.6", "@popperjs/core@^2.11.8", "@popperjs/core@^2.9.2":
|
||||
version "2.11.8"
|
||||
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz"
|
||||
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
|
||||
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
|
||||
|
||||
"@popperjs/core@^2.9.2":
|
||||
version "2.11.6"
|
||||
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz"
|
||||
integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==
|
||||
|
||||
"@tabler/core@1.0.0-beta20":
|
||||
version "1.0.0-beta20"
|
||||
resolved "https://registry.npmjs.org/@tabler/core/-/core-1.0.0-beta20.tgz"
|
||||
@@ -138,6 +150,13 @@
|
||||
resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
|
||||
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
|
||||
|
||||
"@types/node@^20.11.16":
|
||||
version "20.11.16"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.16.tgz#4411f79411514eb8e2926f036c86c9f0e4ec6708"
|
||||
integrity sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==
|
||||
dependencies:
|
||||
undici-types "~5.26.4"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@^5.39.0":
|
||||
version "5.39.0"
|
||||
resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.39.0.tgz"
|
||||
@@ -152,7 +171,7 @@
|
||||
semver "^7.3.7"
|
||||
tsutils "^3.21.0"
|
||||
|
||||
"@typescript-eslint/parser@^5.0.0", "@typescript-eslint/parser@^5.39.0":
|
||||
"@typescript-eslint/parser@^5.39.0":
|
||||
version "5.39.0"
|
||||
resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.39.0.tgz"
|
||||
integrity sha512-PhxLjrZnHShe431sBAGHaNe6BDdxAASDySgsBCGxcBecVCi8NQWxQZMcizNA4g0pN51bBAn/FUfkWG3SDVcGlA==
|
||||
@@ -223,7 +242,7 @@ acorn-jsx@^5.3.2:
|
||||
resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
|
||||
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
|
||||
|
||||
"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.8.0:
|
||||
acorn@^8.8.0:
|
||||
version "8.8.0"
|
||||
resolved "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz"
|
||||
integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==
|
||||
@@ -577,6 +596,71 @@ es-to-primitive@^1.2.1:
|
||||
is-date-object "^1.0.1"
|
||||
is-symbol "^1.0.2"
|
||||
|
||||
esbuild-android-64@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be"
|
||||
integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==
|
||||
|
||||
esbuild-android-arm64@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.13.15.tgz#3fc3ff0bab76fe35dd237476b5d2b32bb20a3d44"
|
||||
integrity sha512-m602nft/XXeO8YQPUDVoHfjyRVPdPgjyyXOxZ44MK/agewFFkPa8tUo6lAzSWh5Ui5PB4KR9UIFTSBKh/RrCmg==
|
||||
|
||||
esbuild-android-arm64@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771"
|
||||
integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==
|
||||
|
||||
esbuild-darwin-64@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.13.15.tgz#8e9169c16baf444eacec60d09b24d11b255a8e72"
|
||||
integrity sha512-ihOQRGs2yyp7t5bArCwnvn2Atr6X4axqPpEdCFPVp7iUj4cVSdisgvEKdNR7yH3JDjW6aQDw40iQFoTqejqxvQ==
|
||||
|
||||
esbuild-darwin-64@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25"
|
||||
integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==
|
||||
|
||||
esbuild-darwin-arm64@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.15.tgz#1b07f893b632114f805e188ddfca41b2b778229a"
|
||||
integrity sha512-i1FZssTVxUqNlJ6cBTj5YQj4imWy3m49RZRnHhLpefFIh0To05ow9DTrXROTE1urGTQCloFUXTX8QfGJy1P8dQ==
|
||||
|
||||
esbuild-darwin-arm64@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73"
|
||||
integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==
|
||||
|
||||
esbuild-freebsd-64@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.15.tgz#0b8b7eca1690c8ec94c75680c38c07269c1f4a85"
|
||||
integrity sha512-G3dLBXUI6lC6Z09/x+WtXBXbOYQZ0E8TDBqvn7aMaOCzryJs8LyVXKY4CPnHFXZAbSwkCbqiPuSQ1+HhrNk7EA==
|
||||
|
||||
esbuild-freebsd-64@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d"
|
||||
integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==
|
||||
|
||||
esbuild-freebsd-arm64@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.15.tgz#2e1a6c696bfdcd20a99578b76350b41db1934e52"
|
||||
integrity sha512-KJx0fzEDf1uhNOZQStV4ujg30WlnwqUASaGSFPhznLM/bbheu9HhqZ6mJJZM32lkyfGJikw0jg7v3S0oAvtvQQ==
|
||||
|
||||
esbuild-freebsd-arm64@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48"
|
||||
integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==
|
||||
|
||||
esbuild-linux-32@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.13.15.tgz#6fd39f36fc66dd45b6b5f515728c7bbebc342a69"
|
||||
integrity sha512-ZvTBPk0YWCLMCXiFmD5EUtB30zIPvC5Itxz0mdTu/xZBbbHJftQgLWY49wEPSn2T/TxahYCRDWun5smRa0Tu+g==
|
||||
|
||||
esbuild-linux-32@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5"
|
||||
integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==
|
||||
|
||||
esbuild-linux-64@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.13.15.tgz"
|
||||
@@ -587,6 +671,76 @@ esbuild-linux-64@0.14.54:
|
||||
resolved "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz"
|
||||
integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==
|
||||
|
||||
esbuild-linux-arm64@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.15.tgz#3891aa3704ec579a1b92d2a586122e5b6a2bfba1"
|
||||
integrity sha512-bYpuUlN6qYU9slzr/ltyLTR9YTBS7qUDymO8SV7kjeNext61OdmqFAzuVZom+OLW1HPHseBfJ/JfdSlx8oTUoA==
|
||||
|
||||
esbuild-linux-arm64@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b"
|
||||
integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==
|
||||
|
||||
esbuild-linux-arm@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.13.15.tgz#8a00e99e6a0c6c9a6b7f334841364d8a2b4aecfe"
|
||||
integrity sha512-wUHttDi/ol0tD8ZgUMDH8Ef7IbDX+/UsWJOXaAyTdkT7Yy9ZBqPg8bgB/Dn3CZ9SBpNieozrPRHm0BGww7W/jA==
|
||||
|
||||
esbuild-linux-arm@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59"
|
||||
integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==
|
||||
|
||||
esbuild-linux-mips64le@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.15.tgz#36b07cc47c3d21e48db3bb1f4d9ef8f46aead4f7"
|
||||
integrity sha512-KlVjIG828uFPyJkO/8gKwy9RbXhCEUeFsCGOJBepUlpa7G8/SeZgncUEz/tOOUJTcWMTmFMtdd3GElGyAtbSWg==
|
||||
|
||||
esbuild-linux-mips64le@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34"
|
||||
integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==
|
||||
|
||||
esbuild-linux-ppc64le@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.15.tgz#f7e6bba40b9a11eb9dcae5b01550ea04670edad2"
|
||||
integrity sha512-h6gYF+OsaqEuBjeesTBtUPw0bmiDu7eAeuc2OEH9S6mV9/jPhPdhOWzdeshb0BskRZxPhxPOjqZ+/OqLcxQwEQ==
|
||||
|
||||
esbuild-linux-ppc64le@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e"
|
||||
integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==
|
||||
|
||||
esbuild-linux-riscv64@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8"
|
||||
integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==
|
||||
|
||||
esbuild-linux-s390x@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6"
|
||||
integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==
|
||||
|
||||
esbuild-netbsd-64@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.15.tgz#a2fedc549c2b629d580a732d840712b08d440038"
|
||||
integrity sha512-3+yE9emwoevLMyvu+iR3rsa+Xwhie7ZEHMGDQ6dkqP/ndFzRHkobHUKTe+NCApSqG5ce2z4rFu+NX/UHnxlh3w==
|
||||
|
||||
esbuild-netbsd-64@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81"
|
||||
integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==
|
||||
|
||||
esbuild-openbsd-64@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.15.tgz#b22c0e5806d3a1fbf0325872037f885306b05cd7"
|
||||
integrity sha512-wTfvtwYJYAFL1fSs8yHIdf5GEE4NkbtbXtjLWjM3Cw8mmQKqsg8kTiqJ9NJQe5NX/5Qlo7Xd9r1yKMMkHllp5g==
|
||||
|
||||
esbuild-openbsd-64@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b"
|
||||
integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==
|
||||
|
||||
esbuild-sass-plugin@^2.3.3:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.npmjs.org/esbuild-sass-plugin/-/esbuild-sass-plugin-2.3.3.tgz"
|
||||
@@ -596,6 +750,46 @@ esbuild-sass-plugin@^2.3.3:
|
||||
resolve "^1.22.1"
|
||||
sass "^1.49.0"
|
||||
|
||||
esbuild-sunos-64@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.13.15.tgz#d0b6454a88375ee8d3964daeff55c85c91c7cef4"
|
||||
integrity sha512-lbivT9Bx3t1iWWrSnGyBP9ODriEvWDRiweAs69vI+miJoeKwHWOComSRukttbuzjZ8r1q0mQJ8Z7yUsDJ3hKdw==
|
||||
|
||||
esbuild-sunos-64@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da"
|
||||
integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==
|
||||
|
||||
esbuild-windows-32@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.13.15.tgz#c96d0b9bbb52f3303322582ef8e4847c5ad375a7"
|
||||
integrity sha512-fDMEf2g3SsJ599MBr50cY5ve5lP1wyVwTe6aLJsM01KtxyKkB4UT+fc5MXQFn3RLrAIAZOG+tHC+yXObpSn7Nw==
|
||||
|
||||
esbuild-windows-32@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31"
|
||||
integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==
|
||||
|
||||
esbuild-windows-64@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.13.15.tgz#1f79cb9b1e1bb02fb25cd414cb90d4ea2892c294"
|
||||
integrity sha512-9aMsPRGDWCd3bGjUIKG/ZOJPKsiztlxl/Q3C1XDswO6eNX/Jtwu4M+jb6YDH9hRSUflQWX0XKAfWzgy5Wk54JQ==
|
||||
|
||||
esbuild-windows-64@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4"
|
||||
integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==
|
||||
|
||||
esbuild-windows-arm64@0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.15.tgz#482173070810df22a752c686509c370c3be3b3c3"
|
||||
integrity sha512-zzvyCVVpbwQQATaf3IG8mu1IwGEiDxKkYUdA4FpoCHi1KtPa13jeScYDjlW0Qh+ebWzpKfR2ZwvqAQkSWNcKjA==
|
||||
|
||||
esbuild-windows-arm64@0.14.54:
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982"
|
||||
integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==
|
||||
|
||||
esbuild@^0.13.15:
|
||||
version "0.13.15"
|
||||
resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.13.15.tgz"
|
||||
@@ -689,7 +883,7 @@ eslint-module-utils@^2.7.3:
|
||||
dependencies:
|
||||
debug "^3.2.7"
|
||||
|
||||
eslint-plugin-import@*, eslint-plugin-import@^2.26.0:
|
||||
eslint-plugin-import@^2.26.0:
|
||||
version "2.26.0"
|
||||
resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz"
|
||||
integrity sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==
|
||||
@@ -748,7 +942,7 @@ eslint-visitor-keys@^3.3.0:
|
||||
resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz"
|
||||
integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==
|
||||
|
||||
eslint@*, "eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8", "eslint@^6.0.0 || ^7.0.0 || ^8.0.0", eslint@^8.24.0, eslint@>=5, eslint@>=7.0.0, eslint@>=7.28.0:
|
||||
eslint@^8.24.0:
|
||||
version "8.24.0"
|
||||
resolved "https://registry.npmjs.org/eslint/-/eslint-8.24.0.tgz"
|
||||
integrity sha512-dWFaPhGhTAiPcCgm3f6LI2MBWbogMnTJzFBbhXVRQDJPkr9pGZvVjlVfXd+vyDcWPA2Ic9L2AXPIQM0+vk/cSQ==
|
||||
@@ -909,7 +1103,7 @@ flat-cache@^3.0.4:
|
||||
flatted "^3.1.0"
|
||||
rimraf "^3.0.2"
|
||||
|
||||
flatpickr@^4.6.13, flatpickr@4.6.13:
|
||||
flatpickr@4.6.13:
|
||||
version "4.6.13"
|
||||
resolved "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz"
|
||||
integrity sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==
|
||||
@@ -924,6 +1118,11 @@ fs.realpath@^1.0.0:
|
||||
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
|
||||
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
|
||||
|
||||
fsevents@~2.3.2:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
||||
|
||||
function-bind@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
|
||||
@@ -1089,7 +1288,7 @@ graphql-language-service@^5.0.6:
|
||||
nullthrows "^1.0.0"
|
||||
vscode-languageserver-types "^3.15.1"
|
||||
|
||||
"graphql@^15.5.0 || ^16.0.0", "graphql@>= v14.5.0 <= 15.5.0", graphql@>=0.10.0:
|
||||
"graphql@>= v14.5.0 <= 15.5.0":
|
||||
version "15.5.0"
|
||||
resolved "https://registry.npmjs.org/graphql/-/graphql-15.5.0.tgz"
|
||||
integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA==
|
||||
@@ -1121,12 +1320,7 @@ has-property-descriptors@^1.0.0:
|
||||
dependencies:
|
||||
get-intrinsic "^1.1.1"
|
||||
|
||||
has-symbols@^1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz"
|
||||
integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
|
||||
|
||||
has-symbols@^1.0.2:
|
||||
has-symbols@^1.0.1, has-symbols@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz"
|
||||
integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
|
||||
@@ -1268,14 +1462,7 @@ is-extglob@^2.1.1:
|
||||
resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz"
|
||||
integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
|
||||
|
||||
is-glob@^4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz"
|
||||
integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
|
||||
dependencies:
|
||||
is-extglob "^2.1.1"
|
||||
|
||||
is-glob@^4.0.1:
|
||||
is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz"
|
||||
integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
|
||||
@@ -1289,13 +1476,6 @@ is-glob@^4.0.3:
|
||||
dependencies:
|
||||
is-extglob "^2.1.1"
|
||||
|
||||
is-glob@~4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz"
|
||||
integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
|
||||
dependencies:
|
||||
is-extglob "^2.1.1"
|
||||
|
||||
is-negative-zero@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz"
|
||||
@@ -1417,11 +1597,6 @@ json5@^1.0.1:
|
||||
dependencies:
|
||||
minimist "^1.2.0"
|
||||
|
||||
just-debounce-it@^3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.npmjs.org/just-debounce-it/-/just-debounce-it-3.1.1.tgz"
|
||||
integrity sha512-oPsuRyWp99LJaQ4KXC3A42tQNqkRTcPy0A8BCkRZ5cPCgsx81upB2KUrmHZvDUNhnCDKe7MshfTuWFQB9iXwDg==
|
||||
|
||||
levn@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz"
|
||||
@@ -1521,11 +1696,6 @@ minimist@^1.2.6:
|
||||
resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz"
|
||||
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
|
||||
|
||||
ms@^2.1.1:
|
||||
version "2.1.3"
|
||||
resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
|
||||
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
||||
|
||||
ms@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
|
||||
@@ -1536,22 +1706,16 @@ ms@2.1.2:
|
||||
resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
|
||||
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
|
||||
|
||||
ms@^2.1.1:
|
||||
version "2.1.3"
|
||||
resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
|
||||
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
||||
|
||||
natural-compare@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
|
||||
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
|
||||
|
||||
"netbox-graphiql@file:/home/jstretch/projects/netbox/netbox/project-static/netbox-graphiql":
|
||||
version "0.1.0"
|
||||
resolved "file:netbox-graphiql"
|
||||
dependencies:
|
||||
graphiql "1.8.9"
|
||||
graphql ">= v14.5.0 <= 15.5.0"
|
||||
react "17.0.2"
|
||||
react-dom "17.0.2"
|
||||
subscriptions-transport-ws "0.9.18"
|
||||
whatwg-fetch "3.6.2"
|
||||
|
||||
normalize-path@^3.0.0, normalize-path@~3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz"
|
||||
@@ -1697,7 +1861,7 @@ prettier-linter-helpers@^1.0.0:
|
||||
dependencies:
|
||||
fast-diff "^1.1.2"
|
||||
|
||||
prettier@^2.7.1, prettier@>=2.0.0:
|
||||
prettier@^2.7.1:
|
||||
version "2.7.1"
|
||||
resolved "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz"
|
||||
integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==
|
||||
@@ -1722,7 +1886,7 @@ queue-microtask@^1.2.2:
|
||||
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
|
||||
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
|
||||
|
||||
"react-dom@^16.8.0 || ^17.0.0 || ^18.0.0", react-dom@17.0.2:
|
||||
react-dom@17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz"
|
||||
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
|
||||
@@ -1731,7 +1895,7 @@ queue-microtask@^1.2.2:
|
||||
object-assign "^4.1.1"
|
||||
scheduler "^0.20.2"
|
||||
|
||||
"react@^16.8.0 || ^17.0.0 || ^18.0.0", react@17.0.2:
|
||||
react@17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.npmjs.org/react/-/react-17.0.2.tgz"
|
||||
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
|
||||
@@ -1878,11 +2042,6 @@ slash@^4.0.0:
|
||||
resolved "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz"
|
||||
integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==
|
||||
|
||||
slim-select@^1.27.1:
|
||||
version "1.27.1"
|
||||
resolved "https://registry.npmjs.org/slim-select/-/slim-select-1.27.1.tgz"
|
||||
integrity sha512-LvJ02cKKk6/jSHIcQv7dZwkQSXHLCVQR3v3lo8RJUssUUcmKPkpBmTpQ8au8KSMkxwca9+yeg+dO0iHAaVr5Aw==
|
||||
|
||||
"source-map-js@>=0.6.2 <2.0.0":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz"
|
||||
@@ -2004,6 +2163,14 @@ toggle-selection@^1.0.6:
|
||||
resolved "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz"
|
||||
integrity sha1-bkWxJj8gF/oKzH2J14sVuL932jI=
|
||||
|
||||
tom-select@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.npmjs.org/tom-select/-/tom-select-2.3.1.tgz"
|
||||
integrity sha512-QS4vnOcB6StNGqX4sGboGXL2fkhBF2gIBB+8Hwv30FZXYPn0CyYO8kkdATRvwfCTThxiR4WcXwKJZ3cOmtI9eg==
|
||||
dependencies:
|
||||
"@orchidjs/sifter" "^1.0.3"
|
||||
"@orchidjs/unicode-variants" "^1.0.4"
|
||||
|
||||
tsconfig-paths@^3.14.1:
|
||||
version "3.14.1"
|
||||
resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz"
|
||||
@@ -2053,7 +2220,7 @@ typeface-roboto-mono@^1.1.13:
|
||||
resolved "https://registry.npmjs.org/typeface-roboto-mono/-/typeface-roboto-mono-1.1.13.tgz"
|
||||
integrity sha512-pnzDc70b7ywJHin/BUFL7HZX8DyOTBLT2qxlJ92eH1UJOFcENIBXa9IZrxsJX/gEKjbEDKhW5vz/TKRBNk/ufQ==
|
||||
|
||||
"typescript@>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta", typescript@~4.8.4:
|
||||
typescript@~4.8.4:
|
||||
version "4.8.4"
|
||||
resolved "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz"
|
||||
integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==
|
||||
@@ -2073,6 +2240,11 @@ unbox-primitive@^1.0.2:
|
||||
has-symbols "^1.0.3"
|
||||
which-boxed-primitive "^1.0.2"
|
||||
|
||||
undici-types@~5.26.4:
|
||||
version "5.26.5"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
|
||||
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
|
||||
|
||||
uri-js@^4.2.2:
|
||||
version "4.4.1"
|
||||
resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} class="{% if 'size' in widget.attrs %}form-select form-select-sm{% else %}netbox-static-select{% endif %}{% if 'class' in widget.attrs %} {{ widget.attrs.class }}{% endif %}">{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
|
||||
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} class="form-select {% if 'class' in widget.attrs %} {{ widget.attrs.class }}{% endif %}">{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
|
||||
<optgroup label="{{ group_name }}">{% endif %}{% for option in group_choices %}
|
||||
{% include option.template_name with widget=option %}{% endfor %}{% if group_name %}
|
||||
</optgroup>{% endif %}{% endfor %}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
{% load humanize %}
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
<p>
|
||||
{% if job.started %}
|
||||
{% trans "Started" %}: <strong>{{ job.started|annotated_date }}</strong>
|
||||
{% elif job.scheduled %}
|
||||
{% trans "Scheduled for" %}: <strong>{{ job.scheduled|annotated_date }}</strong> ({{ job.scheduled|naturaltime }})
|
||||
{% else %}
|
||||
{% trans "Created" %}: <strong>{{ job.created|annotated_date }}</strong>
|
||||
{% endif %}
|
||||
{% if job.completed %}
|
||||
{% trans "Duration" %}: <strong>{{ job.duration }}</strong>
|
||||
{% endif %}
|
||||
<span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
|
||||
</p>
|
||||
{% if job.completed %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Report Methods" %}</h5>
|
||||
<table class="table table-hover">
|
||||
{% for method, data in job.data.items %}
|
||||
<tr>
|
||||
<td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
|
||||
<td class="text-end report-stats">
|
||||
<span class="badge text-bg-success">{{ data.success }}</span>
|
||||
<span class="badge text-bg-info">{{ data.info }}</span>
|
||||
<span class="badge text-bg-warning">{{ data.warning }}</span>
|
||||
<span class="badge text-bg-danger">{{ data.failure }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Report Results" %}</h5>
|
||||
<table class="table table-hover report">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Time" %}</th>
|
||||
<th>{% trans "Level" %}</th>
|
||||
<th>{% trans "Object" %}</th>
|
||||
<th>{% trans "Message" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for method, data in job.data.items %}
|
||||
<tr>
|
||||
<th colspan="4" style="font-family: monospace">
|
||||
<a name="{{ method }}"></a>{{ method }}
|
||||
</th>
|
||||
</tr>
|
||||
{% for time, level, obj, url, message in data.log %}
|
||||
<tr class="{% if level == 'failure' %}danger{% elif level %}{{ level }}{% endif %}">
|
||||
<td>{{ time }}</td>
|
||||
<td>
|
||||
<label class="badge text-bg-{% if level == 'failure' %}danger{% else %}{{ level }}{% endif %}">{{ level|title }}</label>
|
||||
</td>
|
||||
<td>
|
||||
{% if obj and url %}
|
||||
<a href="{{ url }}">{{ obj }}</a>
|
||||
{% elif obj %}
|
||||
{{ obj }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="rendered-markdown">{{ message|markdown }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% elif job.started %}
|
||||
{% include 'extras/inc/result_pending.html' %}
|
||||
{% endif %}
|
||||
@@ -17,39 +17,109 @@
|
||||
<span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
|
||||
</p>
|
||||
{% if job.completed %}
|
||||
<div class="card mb-3">
|
||||
<h5 class="card-header">{% trans "Script Log" %}</h5>
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th>{% trans "Line" %}</th>
|
||||
<th>{% trans "Level" %}</th>
|
||||
<th>{% trans "Message" %}</th>
|
||||
</tr>
|
||||
{% for log in job.data.log %}
|
||||
<tr>
|
||||
<td>{{ forloop.counter }}</td>
|
||||
<td>{% log_level log.status %}</td>
|
||||
<td class="rendered-markdown">{{ log.message|markdown }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-muted">
|
||||
{% trans "No log output" %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if execution_time %}
|
||||
<div class="card-footer text-end text-muted">
|
||||
<small>{% trans "Exec Time" %}: {{ execution_time|floatformat:3 }} {% trans "seconds" context "Unit of time" %}</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h4>{% trans "Output" %}</h4>
|
||||
{% if job.data.output %}
|
||||
<pre class="block">{{ job.data.output }}</pre>
|
||||
{% else %}
|
||||
<p class="text-muted">{% trans "None" %}</p>
|
||||
|
||||
{# Script log. Legacy reports will not have this. #}
|
||||
{% if 'log' in job.data %}
|
||||
<div class="card mb-3">
|
||||
<h5 class="card-header">{% trans "Log" %}</h5>
|
||||
{% if job.data.log %}
|
||||
<table class="table table-hover panel-body">
|
||||
<tr>
|
||||
<th>{% trans "Line" %}</th>
|
||||
<th>{% trans "Time" %}</th>
|
||||
<th>{% trans "Level" %}</th>
|
||||
<th>{% trans "Message" %}</th>
|
||||
</tr>
|
||||
{% for log in job.data.log %}
|
||||
<tr>
|
||||
<td>{{ forloop.counter }}</td>
|
||||
<td>{{ log.time|placeholder }}</td>
|
||||
<td>{% log_level log.status %}</td>
|
||||
<td>{{ log.message|markdown }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body text-muted">{% trans "None" %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Script output. Legacy reports will not have this. #}
|
||||
{% if 'output' in job.data %}
|
||||
<div class="card mb-3">
|
||||
<h5 class="card-header">{% trans "Output" %}</h5>
|
||||
{% if job.data.output %}
|
||||
<pre class="card-body font-monospace">{{ job.data.output }}</pre>
|
||||
{% else %}
|
||||
<div class="card-body text-muted">{% trans "None" %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Test method logs (for legacy Reports) #}
|
||||
{% if tests %}
|
||||
|
||||
{# Summary of test methods #}
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Test Summary" %}</h5>
|
||||
<table class="table table-hover">
|
||||
{% for test, data in tests.items %}
|
||||
<tr>
|
||||
<td class="font-monospace"><a href="#{{ test }}">{{ test }}</a></td>
|
||||
<td class="text-end report-stats">
|
||||
<span class="badge text-bg-success">{{ data.success }}</span>
|
||||
<span class="badge text-bg-info">{{ data.info }}</span>
|
||||
<span class="badge text-bg-warning">{{ data.warning }}</span>
|
||||
<span class="badge text-bg-danger">{{ data.failure }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Detailed results for individual tests #}
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Test Details" %}</h5>
|
||||
<table class="table table-hover report">
|
||||
<thead>
|
||||
<tr class="table-headings">
|
||||
<th>{% trans "Time" %}</th>
|
||||
<th>{% trans "Level" %}</th>
|
||||
<th>{% trans "Object" %}</th>
|
||||
<th>{% trans "Message" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for test, data in tests.items %}
|
||||
<tr>
|
||||
<th colspan="4" style="font-family: monospace">
|
||||
<a name="{{ test }}"></a>{{ test }}
|
||||
</th>
|
||||
</tr>
|
||||
{% for time, level, obj, url, message in data.log %}
|
||||
<tr class="{% if level == 'failure' %}danger{% elif level %}{{ level }}{% endif %}">
|
||||
<td>{{ time }}</td>
|
||||
<td>
|
||||
<label class="badge text-bg-{% if level == 'failure' %}danger{% else %}{{ level }}{% endif %}">{{ level|title }}</label>
|
||||
</td>
|
||||
<td>
|
||||
{% if obj and url %}
|
||||
<a href="{{ url }}">{{ obj }}</a>
|
||||
{% elif obj %}
|
||||
{{ obj }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="rendered-markdown">{{ message|markdown }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
{% elif job.started %}
|
||||
{% include 'extras/inc/result_pending.html' %}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
{% extends 'extras/report/base.html' %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div role="tabpanel" class="tab-pane active" id="report">
|
||||
{% if perms.extras.run_report %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% if not report.is_valid %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="mdi mdi-alert"></i>
|
||||
{% trans "This report is invalid and cannot be run." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post" class="object-edit">
|
||||
{% csrf_token %}
|
||||
{% render_form form %}
|
||||
<div class="float-end">
|
||||
<button type="submit" name="_run" class="btn btn-primary"{% if not report.is_valid %} disabled{% endif %}>
|
||||
{% if report.result %}
|
||||
<i class="mdi mdi-replay"></i> {% trans "Run Again" %}
|
||||
{% else %}
|
||||
<i class="mdi mdi-play"></i> {% trans "Run Report" %}
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% if report.result %}
|
||||
{% trans "Last run" %}: <a href="{% url 'extras:report_result' job_pk=report.result.pk %}">
|
||||
<strong>{{ report.result.created|annotated_date }}</strong>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -1,128 +0,0 @@
|
||||
{% extends 'generic/_base.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load perms %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Reports" %}{% endblock %}
|
||||
|
||||
{% block tabs %}
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link active" role="tab">{% trans "Reports" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endblock tabs %}
|
||||
|
||||
{% block controls %}
|
||||
{% add_button model %}
|
||||
{% endblock controls %}
|
||||
|
||||
{% block content %}
|
||||
{% for module in report_modules %}
|
||||
<div class="card">
|
||||
<h5 class="card-header justify-content-between" id="module{{ module.pk }}">
|
||||
<div>
|
||||
<i class="mdi mdi-file-document-outline"></i> {{ module }}
|
||||
</div>
|
||||
{% if perms.extras.delete_reportmodule %}
|
||||
<a href="{% url 'extras:reportmodule_delete' pk=module.pk %}" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% include 'inc/sync_warning.html' with object=module %}
|
||||
{% if module.reports %}
|
||||
<table class="table table-hover reports">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="250">{% trans "Name" %}</th>
|
||||
<th>{% trans "Description" %}</th>
|
||||
<th>{% trans "Last Run" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th width="120"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% with jobs=module.get_latest_jobs %}
|
||||
{% for report_name, report in module.reports.items %}
|
||||
{% with last_job=jobs|get_key:report.class_name %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'extras:report' module=module.python_name name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
|
||||
</td>
|
||||
<td>{{ report.description|markdown|placeholder }}</td>
|
||||
{% if last_job %}
|
||||
<td>
|
||||
<a href="{% url 'extras:report_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% badge last_job.get_status_display last_job.get_status_color %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="text-muted">{% trans "Never" %}</td>
|
||||
<td>
|
||||
{% if report.is_valid %}
|
||||
{{ ''|placeholder }}
|
||||
{% else %}
|
||||
<span class="badge text-bg-danger" title="{% trans "Report has no test methods" %}">
|
||||
{% trans "Invalid" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
{% if perms.extras.run_report and report.is_valid %}
|
||||
<div class="float-end d-print-none">
|
||||
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" name="_run" class="btn btn-primary" style="width: 110px">
|
||||
{% if last_job %}
|
||||
<i class="mdi mdi-replay"></i> {% trans "Run Again" %}
|
||||
{% else %}
|
||||
<i class="mdi mdi-play"></i> {% trans "Run Report" %}
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% for method, stats in last_job.data.items %}
|
||||
<tr>
|
||||
<td colspan="4" class="method">
|
||||
<span class="ps-3">{{ method }}</span>
|
||||
</td>
|
||||
<td class="text-end text-nowrap report-stats">
|
||||
<span class="badge text-bg-success">{{ stats.success }}</span>
|
||||
<span class="badge text-bg-info">{{ stats.info }}</span>
|
||||
<span class="badge text-bg-warning">{{ stats.warning }}</span>
|
||||
<span class="badge text-bg-danger">{{ stats.failure }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="mdi mdi-alert"></i> Could not load reports from {{ module.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
<h4 class="alert-heading">{% trans "No Reports Found" %}</h4>
|
||||
{% if perms.extras.add_reportmodule %}
|
||||
{% url 'extras:reportmodule_add' as create_report_url %}
|
||||
{% blocktrans trimmed %}
|
||||
Get started by <a href="{{ create_report_url }}">creating a report</a> from an uploaded file or data source.
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock content %}
|
||||
@@ -1,17 +0,0 @@
|
||||
{% extends 'extras/report.html' %}
|
||||
{% load buttons %}
|
||||
{% load perms %}
|
||||
|
||||
{% block controls %}
|
||||
{% if request.user|can_delete:job %}
|
||||
{% delete_button job %}
|
||||
{% endif %}
|
||||
{% endblock controls %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-12"{% if not job.completed %} hx-get="{% url 'extras:report_result' job_pk=job.pk %}" hx-trigger="load delay:0.5s, every 5s"{% endif %}>
|
||||
{% include 'extras/htmx/report_result.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -1,14 +1,11 @@
|
||||
{% extends 'generic/_base.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load perms %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Scripts" %}{% endblock %}
|
||||
|
||||
{% block controls %}
|
||||
{% add_button model %}
|
||||
{% endblock controls %}
|
||||
|
||||
{% block tabs %}
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="nav-item" role="presentation">
|
||||
@@ -17,73 +14,117 @@
|
||||
</ul>
|
||||
{% endblock tabs %}
|
||||
|
||||
{% block controls %}
|
||||
{% add_button model %}
|
||||
{% endblock controls %}
|
||||
|
||||
{% block content %}
|
||||
{% for module in script_modules %}
|
||||
{% include 'inc/sync_warning.html' with object=module %}
|
||||
<div class="card">
|
||||
<h5 class="card-header justify-content-between" id="module{{ module.pk }}">
|
||||
<div>
|
||||
<i class="mdi mdi-file-document-outline"></i> {{ module }}
|
||||
</div>
|
||||
{% if perms.extras.delete_scriptmodule %}
|
||||
<div class="float-end">
|
||||
<a href="{% url 'extras:scriptmodule_delete' pk=module.pk %}" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
|
||||
</a>
|
||||
</div>
|
||||
<a href="{% url 'extras:scriptmodule_delete' pk=module.pk %}" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% include 'inc/sync_warning.html' with object=module %}
|
||||
{% if not module.scripts %}
|
||||
<div class="alert alert-warning d-flex align-items-center" role="alert">
|
||||
<i class="mdi mdi-alert"></i>
|
||||
{% blocktrans trimmed with file_path=module.full_path %}
|
||||
Script file at <code class="mx-1">{{ file_path }}</code> could not be loaded.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% else %}
|
||||
<table class="table table-hover reports">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="250">{% trans "Name" %}</th>
|
||||
<th>{% trans "Description" %}</th>
|
||||
<th>{% trans "Last Run" %}</th>
|
||||
<th class="text-end">{% trans "Status" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% with jobs=module.get_latest_jobs %}
|
||||
{% for script_name, script_class in module.scripts.items %}
|
||||
{% if module.scripts %}
|
||||
<table class="table table-hover scripts">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Description" %}</th>
|
||||
<th>{% trans "Last Run" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% with jobs=module.get_latest_jobs %}
|
||||
{% for script_name, script in module.scripts.items %}
|
||||
{% with last_job=jobs|get_key:script.class_name %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'extras:script' module=module.python_name name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
|
||||
<a href="{% url 'extras:script' module=module.python_name name=script.class_name %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.name }}</a>
|
||||
</td>
|
||||
<td>{{ script.description|markdown|placeholder }}</td>
|
||||
{% if last_job %}
|
||||
<td>
|
||||
<a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% badge last_job.get_status_display last_job.get_status_color %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="text-muted">{% trans "Never" %}</td>
|
||||
<td>{{ ''|placeholder }}</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
{{ script_class.Meta.description|markdown|placeholder }}
|
||||
</td>
|
||||
{% with last_result=jobs|get_key:script_class.class_name %}
|
||||
{% if last_result %}
|
||||
<td>
|
||||
<a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% badge last_result.get_status_display last_result.get_status_color %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="text-muted">{% trans "Never" %}</td>
|
||||
<td class="text-end">{{ ''|placeholder }}</td>
|
||||
{% if perms.extras.run_script %}
|
||||
<div class="float-end d-print-none">
|
||||
<form action="{% url 'extras:script' module=script.module name=script.class_name %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" name="_run" class="btn btn-primary btn-sm">
|
||||
{% if last_job %}
|
||||
<i class="mdi mdi-replay"></i> {% trans "Run Again" %}
|
||||
{% else %}
|
||||
<i class="mdi mdi-play"></i> {% trans "Run Script" %}
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if last_job %}
|
||||
{% for test_name, data in last_job.data.tests.items %}
|
||||
<tr>
|
||||
<td colspan="4" class="method">
|
||||
<span class="ps-3">{{ test_name }}</span>
|
||||
</td>
|
||||
<td class="text-end text-nowrap script-stats">
|
||||
<span class="badge text-bg-success">{{ data.success }}</span>
|
||||
<span class="badge text-bg-info">{{ data.info }}</span>
|
||||
<span class="badge text-bg-warning">{{ data.warning }}</span>
|
||||
<span class="badge text-bg-danger">{{ data.failure }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% elif not last_job.data.log %}
|
||||
{# legacy #}
|
||||
{% for method, stats in last_job.data.items %}
|
||||
<tr>
|
||||
<td colspan="4" class="method">
|
||||
<span class="ps-3">{{ method }}</span>
|
||||
</td>
|
||||
<td class="text-end text-nowrap report-stats">
|
||||
<span class="badge bg-success">{{ stats.success }}</span>
|
||||
<span class="badge bg-info">{{ stats.info }}</span>
|
||||
<span class="badge bg-warning">{{ stats.warning }}</span>
|
||||
<span class="badge bg-danger">{{ stats.failure }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="mdi mdi-alert"></i> Could not load scripts from {{ module.name }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="alert alert-info">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<h4 class="alert-heading">{% trans "No Scripts Found" %}</h4>
|
||||
{% if perms.extras.add_scriptmodule %}
|
||||
{% url 'extras:scriptmodule_add' as create_script_url %}
|
||||
|
||||
@@ -186,8 +186,7 @@ class UserForm(forms.ModelForm):
|
||||
object_permissions = DynamicModelMultipleChoiceField(
|
||||
required=False,
|
||||
label=_('Permissions'),
|
||||
queryset=ObjectPermission.objects.all(),
|
||||
to_field_name='pk',
|
||||
queryset=ObjectPermission.objects.all()
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
@@ -244,8 +243,7 @@ class GroupForm(forms.ModelForm):
|
||||
object_permissions = DynamicModelMultipleChoiceField(
|
||||
required=False,
|
||||
label=_('Permissions'),
|
||||
queryset=ObjectPermission.objects.all(),
|
||||
to_field_name='pk',
|
||||
queryset=ObjectPermission.objects.all()
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
|
||||
@@ -12,26 +12,10 @@ __all__ = (
|
||||
'ColorField',
|
||||
'CounterCacheField',
|
||||
'NaturalOrderingField',
|
||||
'NullableCharField',
|
||||
'RestrictedGenericForeignKey',
|
||||
)
|
||||
|
||||
|
||||
# Deprecated: Retained only to ensure successful migration from early releases
|
||||
# Use models.CharField(null=True) instead
|
||||
# TODO: Remove in v4.0
|
||||
class NullableCharField(models.CharField):
|
||||
description = "Stores empty values as NULL rather than ''"
|
||||
|
||||
def to_python(self, value):
|
||||
if isinstance(value, models.CharField):
|
||||
return value
|
||||
return value or ''
|
||||
|
||||
def get_prep_value(self, value):
|
||||
return value or None
|
||||
|
||||
|
||||
class ColorField(models.CharField):
|
||||
default_validators = [ColorValidator]
|
||||
description = "A hexadecimal RGB color code"
|
||||
|
||||
@@ -64,8 +64,6 @@ class DynamicModelChoiceMixin:
|
||||
null_option: The string used to represent a null selection (if any)
|
||||
disabled_indicator: The name of the field which, if populated, will disable selection of the
|
||||
choice (optional)
|
||||
fetch_trigger: The event type which will cause the select element to
|
||||
fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional)
|
||||
selector: Include an advanced object selection widget to assist the user in identifying the desired object
|
||||
"""
|
||||
filter = django_filters.ModelChoiceFilter
|
||||
@@ -79,8 +77,6 @@ class DynamicModelChoiceMixin:
|
||||
initial_params=None,
|
||||
null_option=None,
|
||||
disabled_indicator=None,
|
||||
fetch_trigger=None,
|
||||
empty_label=None,
|
||||
selector=False,
|
||||
**kwargs
|
||||
):
|
||||
@@ -89,24 +85,12 @@ class DynamicModelChoiceMixin:
|
||||
self.initial_params = initial_params or {}
|
||||
self.null_option = null_option
|
||||
self.disabled_indicator = disabled_indicator
|
||||
self.fetch_trigger = fetch_trigger
|
||||
self.selector = selector
|
||||
|
||||
# to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference
|
||||
# by widget_attrs()
|
||||
self.to_field_name = kwargs.get('to_field_name')
|
||||
self.empty_option = empty_label or ""
|
||||
|
||||
super().__init__(queryset, **kwargs)
|
||||
|
||||
def widget_attrs(self, widget):
|
||||
attrs = {
|
||||
'data-empty-option': self.empty_option
|
||||
}
|
||||
|
||||
# Set value-field attribute if the field specifies to_field_name
|
||||
if self.to_field_name:
|
||||
attrs['value-field'] = self.to_field_name
|
||||
attrs = {}
|
||||
|
||||
# Set the string used to represent a null option
|
||||
if self.null_option is not None:
|
||||
@@ -116,10 +100,6 @@ class DynamicModelChoiceMixin:
|
||||
if self.disabled_indicator is not None:
|
||||
attrs['disabled-indicator'] = self.disabled_indicator
|
||||
|
||||
# Set the fetch trigger, if any.
|
||||
if self.fetch_trigger is not None:
|
||||
attrs['data-fetch-trigger'] = self.fetch_trigger
|
||||
|
||||
# Attach any static query parameters
|
||||
if (len(self.query_params) > 0):
|
||||
widget.add_query_params(self.query_params)
|
||||
|
||||
@@ -24,7 +24,7 @@ class APISelect(forms.Select):
|
||||
def __init__(self, api_url=None, full=False, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.attrs['class'] = 'netbox-api-select'
|
||||
self.attrs['class'] = 'api-select'
|
||||
self.dynamic_params: Dict[str, List[str]] = {}
|
||||
self.static_params: Dict[str, List[str]] = {}
|
||||
|
||||
@@ -153,8 +153,4 @@ class APISelect(forms.Select):
|
||||
|
||||
|
||||
class APISelectMultiple(APISelect, forms.SelectMultiple):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.attrs['data-multiple'] = 1
|
||||
pass
|
||||
|
||||
@@ -25,7 +25,6 @@ class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
|
||||
('2', 'Yes'),
|
||||
('3', 'No'),
|
||||
)
|
||||
self.attrs['class'] = 'netbox-static-select'
|
||||
|
||||
|
||||
class ColorSelect(forms.Select):
|
||||
@@ -37,7 +36,7 @@ class ColorSelect(forms.Select):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['choices'] = add_blank_choice(ColorChoices)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs['class'] = 'netbox-color-select'
|
||||
self.attrs['class'] = 'color-select'
|
||||
|
||||
|
||||
class HTMXSelect(forms.Select):
|
||||
|
||||
@@ -423,8 +423,7 @@ class L2VPNTerminationForm(NetBoxModelForm):
|
||||
queryset=L2VPN.objects.all(),
|
||||
required=True,
|
||||
query_params={},
|
||||
label=_('L2VPN'),
|
||||
fetch_trigger='open'
|
||||
label=_('L2VPN')
|
||||
)
|
||||
vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
|
||||
Reference in New Issue
Block a user