├── tests └── __init__.py ├── netbox_path ├── api │ ├── __init__.py │ ├── urls.py │ ├── serializers.py │ ├── impact.py │ └── views.py ├── template_content.py ├── migrations │ ├── __init__.py │ ├── 0003_path_image.py │ ├── 0002_alter_path_created_alter_path_custom_field_data_and_more.py │ └── 0001_initial.py ├── admin.py ├── forms.py ├── search.py ├── filtersets.py ├── tables.py ├── templates │ └── netbox_path │ │ ├── region.html │ │ ├── site.html │ │ ├── device.html │ │ ├── rack.html │ │ ├── vlan.html │ │ ├── virtualmachine.html │ │ └── path.html ├── __init__.py ├── navigation.py ├── models.py ├── urls.py └── views.py ├── MANIFEST.in ├── .gitlab-ci.yml ├── .gitignore ├── frontend ├── index.html ├── .gitignore ├── elements.json ├── build.sh ├── package.json ├── selector.js ├── style.css ├── package-lock.json └── main.js ├── setup.py ├── README.rst ├── README.md └── contrib └── scan.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_path/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_path/template_content.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_path/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | recursive-include netbox_path/static * 3 | recursive-include netbox_path/templates * -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | stages: 3 | - demo 4 | 5 | 6 | demo: 7 | stage: demo 8 | only: 9 | - demo 10 | tags: 11 | - netbox-path-demo 12 | script: 13 | - whoami 14 | 15 | -------------------------------------------------------------------------------- /netbox_path/admin.py: -------------------------------------------------------------------------------- 1 | import imp 2 | from django.contrib import admin 3 | from .models import Path 4 | 5 | @admin.register(Path) 6 | class PathAdmin(admin.ModelAdmin): 7 | list_display = ('name', 'description') -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | netbox_path/static/netbox_path/index.css 3 | netbox_path/static/netbox_path/index.js 4 | netbox_path/static/netbox_path/sprite.svg 5 | dist 6 | *.egg-info 7 | .vscode 8 | .DS_Store 9 | build -------------------------------------------------------------------------------- /netbox_path/forms.py: -------------------------------------------------------------------------------- 1 | from netbox.forms import NetBoxModelForm 2 | from .models import Path 3 | 4 | class PathForm(NetBoxModelForm): 5 | 6 | class Meta: 7 | model = Path 8 | fields = ('name', 'description', 'tags') -------------------------------------------------------------------------------- /netbox_path/search.py: -------------------------------------------------------------------------------- 1 | from netbox.search import SearchIndex, register_search 2 | from models import Path 3 | 4 | @register_search 5 | class PathIndex(SearchIndex): 6 | model = Path 7 | fields = ( 8 | ('name', 100), 9 | ('description', 5000), 10 | ) -------------------------------------------------------------------------------- /netbox_path/api/urls.py: -------------------------------------------------------------------------------- 1 | from netbox.api.routers import NetBoxRouter 2 | from . import views 3 | 4 | app_name = 'netbox_path' 5 | 6 | router = NetBoxRouter() 7 | router.register(r'paths', views.PathViewSet) 8 | router.register(r'impact', views.ImpactViewSet, basename='impact-assessment') 9 | 10 | urlpatterns = router.urls 11 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Netbox Path 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /netbox_path/filtersets.py: -------------------------------------------------------------------------------- 1 | from netbox.filtersets import NetBoxModelFilterSet 2 | from .models import Path 3 | 4 | 5 | class PathFilterSet(NetBoxModelFilterSet): 6 | 7 | class Meta: 8 | model = Path 9 | fields = ('id', 'name', 'description') 10 | 11 | def search(self, queryset, name, value): 12 | return queryset.filter(description__icontains=value) 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | setup( 4 | name='netbox_path', 5 | version = '0.3.6', 6 | description='Create visual and queryable maps of logical paths within your infrastructure', 7 | install_requires=[], 8 | url='https://github.com/sol1/netbox-path', 9 | packages=find_packages(), 10 | include_package_data=True, 11 | zip_safe=False, 12 | ) 13 | -------------------------------------------------------------------------------- /frontend/elements.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "data": { 4 | "id": "a", 5 | "label": "A" 6 | } 7 | }, 8 | { 9 | "data": { 10 | "id": "b", 11 | "label": "B" 12 | } 13 | }, 14 | { 15 | "data": { 16 | "id": "d", 17 | "label": "D" 18 | } 19 | }, 20 | { 21 | "data": { "id": "ab", "source": "a", "target": "b" } 22 | } 23 | ] -------------------------------------------------------------------------------- /netbox_path/tables.py: -------------------------------------------------------------------------------- 1 | import django_tables2 as tables 2 | from netbox.tables import NetBoxTable 3 | from .models import Path 4 | 5 | class PathTable(NetBoxTable): 6 | name = tables.Column( 7 | linkify=True 8 | ) 9 | 10 | class Meta(NetBoxTable.Meta): 11 | model = Path 12 | fields = ('pk', 'id', 'name', 'description', 'actions') 13 | default_columns = ('name', 'description') 14 | -------------------------------------------------------------------------------- /netbox_path/migrations/0003_path_image.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.5 on 2023-11-06 08:21 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_path', '0002_alter_path_created_alter_path_custom_field_data_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='path', 15 | name='image', 16 | field=models.TextField(blank=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /netbox_path/templates/netbox_path/region.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load render_table from django_tables2 %} 3 | 4 | {% block title %}{{ object }} - Paths{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
Linked Paths
10 |
11 |
12 | {% render_table table 'inc/table.html' %} 13 | {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} 14 |
15 |
16 | {% endblock content %} -------------------------------------------------------------------------------- /netbox_path/templates/netbox_path/site.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load render_table from django_tables2 %} 3 | 4 | {% block title %}{{ object }} - Paths{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
Linked Paths
10 |
11 |
12 | {% render_table table 'inc/table.html' %} 13 | {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} 14 |
15 |
16 | {% endblock content %} -------------------------------------------------------------------------------- /netbox_path/templates/netbox_path/device.html: -------------------------------------------------------------------------------- 1 | {% extends 'dcim/device/base.html' %} 2 | {% load render_table from django_tables2 %} 3 | 4 | {% block title %}{{ object }} - Paths{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
Linked Paths
10 |
11 |
12 | {% render_table table 'inc/table.html' %} 13 | {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} 14 |
15 |
16 | {% endblock content %} -------------------------------------------------------------------------------- /netbox_path/templates/netbox_path/rack.html: -------------------------------------------------------------------------------- 1 | {% extends 'dcim/rack/base.html' %} 2 | {% load render_table from django_tables2 %} 3 | 4 | {% block title %}Rack {{ object }} - Paths{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
Linked Paths
10 |
11 |
12 | {% render_table table 'inc/table.html' %} 13 | {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} 14 |
15 |
16 | {% endblock content %} -------------------------------------------------------------------------------- /netbox_path/templates/netbox_path/vlan.html: -------------------------------------------------------------------------------- 1 | {% extends 'ipam/vlan/base.html' %} 2 | {% load render_table from django_tables2 %} 3 | 4 | {% block title %}VLAN {{ object }} - Paths{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
Linked Paths
10 |
11 |
12 | {% render_table table 'inc/table.html' %} 13 | {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} 14 |
15 |
16 | {% endblock content %} -------------------------------------------------------------------------------- /netbox_path/templates/netbox_path/virtualmachine.html: -------------------------------------------------------------------------------- 1 | {% extends 'virtualization/virtualmachine/base.html' %} 2 | {% load render_table from django_tables2 %} 3 | 4 | {% block title %}{{ object }} - Paths{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
Linked Paths
10 |
11 |
12 | {% render_table table 'inc/table.html' %} 13 | {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} 14 |
15 |
16 | {% endblock content %} -------------------------------------------------------------------------------- /netbox_path/__init__.py: -------------------------------------------------------------------------------- 1 | from extras.plugins import PluginConfig 2 | 3 | class NetBoxPathConfig(PluginConfig): 4 | name = 'netbox_path' 5 | verbose_name = 'Netbox Path' 6 | description = 'Create visual and queryable maps of logical paths within your infrastructure' 7 | version = '0.3.6' 8 | author = 'Andrew Foster' 9 | author_email = 'support@sol1.com.au' 10 | base_url = 'netbox-path' 11 | required_settings = [] 12 | min_version = '3.5.0' 13 | max_version = '3.9.9' 14 | default_settings = { 15 | 'device_ext_page': 'right', 16 | 'asdot': False 17 | } 18 | 19 | config = NetBoxPathConfig 20 | -------------------------------------------------------------------------------- /frontend/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | LOCAL_DIR="dist/assets/static/netbox_path" 4 | PLUGIN_DIR="../netbox_path/static/netbox_path" 5 | 6 | mkdir -p $LOCAL_DIR 7 | rm -rf dist/assets/* 8 | 9 | npm run build && \ 10 | mkdir -p $LOCAL_DIR 11 | mkdir -p $PLUGIN_DIR 12 | 13 | cp -v dist/assets/sprite.*.svg $LOCAL_DIR/sprite.svg && \ 14 | cp -v dist/assets/index.*.js $LOCAL_DIR/index.js && \ 15 | cp -v dist/assets/index.*.css $LOCAL_DIR/index.css && \ 16 | 17 | cp -v dist/assets/sprite.*.svg $PLUGIN_DIR/sprite.svg && \ 18 | cp -v dist/assets/index.*.js $PLUGIN_DIR/index.js && \ 19 | cp -v dist/assets/index.*.css $PLUGIN_DIR/index.css 20 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "vite": "^3.0.0" 13 | }, 14 | "dependencies": { 15 | "cytoscape": "^3.22.1", 16 | "cytoscape-navigator": "^2.0.2", 17 | "cytoscape-node-html-label": "^1.2.2", 18 | "cytoscape-popper": "^2.0.0", 19 | "html2canvas": "^1.4.1", 20 | "microplugin": "^0.0.3", 21 | "sifter": "^0.0.5", 22 | "tippy.js": "^6.3.7", 23 | "tom-select": "^2.1.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Netbox Path 3 | ===== 4 | 5 | This is a plugin for NetBox that allows you to create visual and queryable maps 6 | of logical paths within your infrastructure. This allows for impact assessment 7 | and monitoring integration. 8 | 9 | The models are implemented using Cytoscape JS and built using Vite. 10 | 11 | Quick start 12 | ----------- 13 | 14 | 1. Add "netbox_path" to your INSTALLED_APPS setting like this:: 15 | 16 | INSTALLED_APPS = [ 17 | ... 18 | 'netbox_path', 19 | ] 20 | 21 | 2. Run ``python manage.py migrate`` to create the path models. 22 | 23 | 3. Start the development server and visit http://127.0.0.1:8000/plugins/netbox-path/paths/ 24 | to create a new path object. -------------------------------------------------------------------------------- /netbox_path/navigation.py: -------------------------------------------------------------------------------- 1 | from extras.plugins import PluginMenu, PluginMenuItem, PluginMenuButton 2 | from utilities.choices import ButtonColorChoices 3 | 4 | add_button = [ 5 | PluginMenuButton( 6 | link='plugins:netbox_path:path_add', 7 | title='Add', 8 | icon_class='mdi mdi-plus-thick', 9 | color=ButtonColorChoices.GREEN, 10 | permissions=["netbox_path.add_path"], 11 | ) 12 | ] 13 | 14 | menu = PluginMenu( 15 | label='Paths', 16 | groups=( 17 | ('Paths', ( 18 | PluginMenuItem(link='plugins:netbox_path:path_list',link_text='Paths', buttons=add_button, permissions=['netbox_path.view_path']), 19 | )), 20 | ), 21 | icon_class='mdi mdi-map-marker-path' 22 | ) 23 | -------------------------------------------------------------------------------- /netbox_path/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.contenttypes.fields import GenericRelation 3 | from django.urls import reverse 4 | 5 | from netbox.models import NetBoxModel 6 | 7 | class Path(NetBoxModel): 8 | name = models.CharField( 9 | max_length=64 10 | ) 11 | description = models.TextField( 12 | blank=True 13 | ) 14 | graph = models.JSONField( 15 | blank=True, 16 | null=True 17 | ) 18 | image = models.TextField(blank=True) 19 | 20 | contacts = GenericRelation( 21 | to='tenancy.ContactAssignment' 22 | ) 23 | 24 | def get_absolute_url(self): 25 | return reverse('plugins:netbox_path:path', args=[self.pk]) 26 | 27 | def __str__(self): 28 | return self.name -------------------------------------------------------------------------------- /netbox_path/api/serializers.py: -------------------------------------------------------------------------------- 1 | from netbox.api.serializers import NetBoxModelSerializer 2 | from rest_framework import serializers 3 | from ..models import Path 4 | 5 | class NestedPathSerializer(NetBoxModelSerializer): 6 | url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_path-api:path-detail') 7 | 8 | class Meta: 9 | model = Path 10 | fields = ( 11 | 'id', 'url', 'display', 'name', 'description', 'tags', 'custom_fields', 'graph' 12 | ) 13 | 14 | class PathSerializer(NetBoxModelSerializer): 15 | url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_path-api:path-detail') 16 | class Meta: 17 | model = Path 18 | fields = ( 19 | 'id', 'url', 'display', 'name', 'description', 'tags', 'custom_fields', 'graph' 20 | ) -------------------------------------------------------------------------------- /netbox_path/migrations/0002_alter_path_created_alter_path_custom_field_data_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.9 on 2023-08-07 05:17 2 | 3 | from django.db import migrations, models 4 | import utilities.json 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('netbox_path', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='path', 16 | name='created', 17 | field=models.DateTimeField(auto_now_add=True, null=True), 18 | ), 19 | migrations.AlterField( 20 | model_name='path', 21 | name='custom_field_data', 22 | field=models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), 23 | ), 24 | migrations.AlterField( 25 | model_name='path', 26 | name='id', 27 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), 28 | ), 29 | ] -------------------------------------------------------------------------------- /netbox_path/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | import django.core.serializers.json 2 | from django.db import migrations, models 3 | import taggit.managers 4 | 5 | class Migration(migrations.Migration): 6 | 7 | initial = True 8 | 9 | operations = [ 10 | migrations.CreateModel( 11 | name='Path', 12 | fields=[ 13 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), 14 | ('created', models.DateField(auto_now_add=True, null=True)), 15 | ('last_updated', models.DateTimeField(auto_now=True, null=True)), 16 | ('name', models.CharField(max_length=64)), 17 | ('description', models.TextField(blank=True)), 18 | ('graph', models.JSONField(blank=True, null=True)), 19 | ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), 20 | ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), 21 | ], 22 | ) 23 | ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NetBox Path 2 | 3 | This is a plugin for NetBox that allows you to create visual and queryable maps 4 | of logical paths within your infrastructure. This allows for impact assessment 5 | and monitoring integration. 6 | 7 | The models are implemented using Cytoscape JS and built using Vite. 8 | 9 | ## Demo deployment 10 | the demo branch deploys to a gitlab runner on netbox-path.sol1.net 11 | pushing to that branch will trigger a new deployment for the demo 12 | 13 | ## Front-end development 14 | 15 | * `cd frontend` 16 | * `npm install` 17 | * `npm run dev` 18 | 19 | 25 | 26 | ## To release 27 | 28 | * `cd frontend && ./build.sh && cd $OLDPWD` 29 | * Bump version in `netbox_path/__init__.py` and `setup.py` 30 | * `python setup.py sdist` writes new tarball to `dist/` 31 | 32 | ## To deploy to a Netbox 33 | 34 | * `cd /srv/netbox/current && source venv-py3/bin/activate` 35 | * Add to Netbox's `local_requirements.txt` something like 36 | 37 | `netbox-path @ file:///srv/netbox/current/contrib/netbox_path-0.2.4.tar.gz` 38 | 39 | * `PYTHON=/usr/bin/python3 ./upgrade.sh` 40 | 41 | Each user must create an API token with **write** permissions. The plugin will automatically use it. 42 | -------------------------------------------------------------------------------- /netbox_path/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from . import models, views 3 | from netbox.views.generic import ObjectChangeLogView, ObjectJournalView 4 | from django.conf import settings 5 | from django.conf.urls.static import static 6 | 7 | urlpatterns = [ 8 | path('paths/', views.PathListView.as_view(), name='path_list'), 9 | path('paths/add/', views.PathEditView.as_view(), name='path_add'), 10 | path('paths//', views.PathView.as_view(), name='path'), 11 | path('paths//edit/', views.PathEditView.as_view(), name='path_edit'), 12 | path('paths//delete/', views.PathDeleteView.as_view(), name='path_delete'), 13 | path('paths//contacts/', views.PathContactsView.as_view(), name='path_contacts'), 14 | path('paths//changelog/', ObjectChangeLogView.as_view(), name='path_changelog', kwargs={"model": models.Path}), 15 | path('paths//journal/', ObjectJournalView.as_view(), name='path_journal', kwargs={"model": models.Path}), 16 | path('dcim/devices//paths/', views.DevicePaths.as_view(), name="device_paths"), 17 | path('dcim/interfaces//paths/', views.InterfacePaths.as_view(), name="interface_paths"), 18 | path('virtualization/interfaces//paths/', views.VMInterfacePaths.as_view(), name="vminterface_paths"), 19 | path('circuits/circuits//paths/', views.CircuitPaths.as_view(), name="circuit_paths"), 20 | path('ipam/vlans//paths/', views.VLANPath.as_view(), name="vlan_paths"), 21 | path('dcim/racks//paths/', views.RackPath.as_view(), name="rack_paths"), 22 | path('dcim/regions//paths/', views.RegionPath.as_view(), name="region_paths"), 23 | path('dcim/sites//paths/', views.SitePath.as_view(), name="site_paths"), 24 | path('tenancy/tenants//paths/', views.TenantPath.as_view(), name="tenant_paths"), 25 | path('virtualization/virtual-machines//paths/', views.VirtualMachinePath.as_view(), name="virtualmachine_paths"), 26 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) -------------------------------------------------------------------------------- /contrib/scan.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | OBJECT_ID = 109 4 | OBJECT_TYPE = 'dcim.devices' 5 | 6 | HOST = 'netbox-path.sol1.net' 7 | API_KEY = '506f3020a10a35d71ca21939abc67e7e90db493b' 8 | 9 | cached_objects = {} 10 | visited_edges = [] 11 | visited_nodes = [] 12 | visited_graphs = [] 13 | 14 | affected_objects = [] 15 | affected_paths = [] 16 | 17 | headers = { 18 | 'Content-Type': 'application/json', 19 | 'Authorization': f'Token {API_KEY}', 20 | } 21 | 22 | def check_edges(node_key, node, object): 23 | node_key = node['id'] 24 | node_type = node['object']['type'] 25 | node_id = node['object']['id'] 26 | 27 | print(f'Found node {node_key} {node_type} {node_id}') 28 | if node_key not in visited_nodes: 29 | if f'{node_type}:{node_id}' not in affected_objects: 30 | affected_objects.append(f'{node_type}:{node_id}') 31 | visited_nodes.append(node_key) 32 | try: 33 | for edge in object['graph']['elements']['edges']: 34 | edge_key = edge['data']['id'] 35 | edge_source = edge['data']['source'] 36 | edge_target = edge['data']['target'] 37 | if edge_source == node_key: 38 | if edge_key not in visited_edges: 39 | visited_edges.append(edge_key) 40 | print(f'Found edge {edge_key} {edge_source}->{edge_target}') 41 | child_node_key = edge['data']['target'] 42 | for child_node in object['graph']['elements']['nodes']: 43 | #print(child_node_key, child_node['data']['id']) 44 | if child_node['data']['id'] == child_node_key: 45 | print(f'Iterate over children of {child_node["data"]["id"]}') 46 | check_object(child_node['data']['object']['id'], child_node['data']['object']['type']) 47 | if child_node_key not in visited_nodes: 48 | visited_nodes.append(child_node_key) 49 | else: 50 | print(f'Already visited edge {edge_key}') 51 | except Exception as e: 52 | print('No edges found') 53 | else: 54 | print(f'Already visited node {node_key}') 55 | 56 | def check_object(object_id, object_type): 57 | object_type = object_type.replace('.', '/') 58 | if f'{object_type}:{object_id}' in cached_objects: 59 | print(f'Already visited this node using cached response {object_type} {object_id}') 60 | response = cached_objects[f'{object_type}:{object_id}'] 61 | else: 62 | response = requests.get(f'https://{HOST}/api/plugins/netbox-path/paths/{object_type}/{object_id}/', headers=headers).json() 63 | print(f'Requesting paths for {object_type} {object_id} https://{HOST}/api/plugins/netbox-path/paths/{object_type}/{object_id}/') 64 | if object_type == OBJECT_TYPE and object_id == OBJECT_ID: 65 | if response['id'] not in affected_paths: 66 | affected_paths.append(response['id']) 67 | cached_objects[f'{object_type}:{object_id}'] = response 68 | 69 | object_type = object_type.replace('/', '.') 70 | for object in response: 71 | path_id = object['id'] 72 | print(f'Visiting path {path_id}') 73 | if path_id not in visited_graphs: 74 | visited_graphs.append(path_id) 75 | 76 | for node in object['graph']['elements']['nodes']: 77 | node = node['data'] 78 | node_key = node['id'] 79 | if node['object']['type'] == object_type and node['object']['id'] == object_id: 80 | check_edges(node_key, node, object) 81 | 82 | def get_contacts(): 83 | response = requests.get(f'https://{HOST}/api/tenancy/contact-assignments/', headers=headers).json() 84 | for object in affected_objects: 85 | type = object.split(':')[0].rsplit('s', 1)[0] 86 | id = object.split(':')[1] 87 | print(f'Checking contacts for {type} {id}') 88 | for assignment in response['results']: 89 | if assignment['content_type'] == type and assignment['object_id'] == int(id): 90 | contact_response = requests.get(assignment['contact']['url'], headers=headers).json() 91 | print(f'{type} {id} - Contact {contact_response["name"]} {contact_response["email"]} {contact_response["phone"]}') 92 | #print(response) 93 | def get_path_contacts(): 94 | response = requests.get(f'https://{HOST}/api/tenancy/contact-assignments/', headers=headers).json() 95 | for path in visited_graphs: 96 | for assignment in response['results']: 97 | if assignment['content_type'] == 'netbox_path.path' and assignment['object_id'] == int(path): 98 | contact_response = requests.get(assignment['contact']['url'], headers=headers).json() 99 | print(f'Path {path} - Contact {contact_response["name"]} {contact_response["email"]} {contact_response["phone"]}') 100 | 101 | check_object(OBJECT_ID, OBJECT_TYPE) 102 | #get_contacts() 103 | get_path_contacts() 104 | 105 | print('') 106 | print(f'Visited graphs: {visited_graphs}') 107 | print(f'Visited nodes: {visited_nodes}') 108 | print(f'Visited edges: {visited_edges}') 109 | -------------------------------------------------------------------------------- /netbox_path/api/impact.py: -------------------------------------------------------------------------------- 1 | from .. import filtersets, models 2 | 3 | from tenancy.models import ContactAssignment 4 | from virtualization.models import VirtualMachine, VMInterface 5 | from tenancy.models import Tenant 6 | from dcim.models import Device, Rack, Region, Site, Interface 7 | from ipam.models import VLAN 8 | from circuits.models import Circuit 9 | 10 | from django.contrib.contenttypes.models import ContentType 11 | from django.forms.models import model_to_dict 12 | 13 | class ImpactAssessment: 14 | 15 | def __init__(self, object_type, object_id): 16 | self.object_type = object_type 17 | self.object_id = int(object_id) 18 | 19 | self.source_nodes = {} 20 | self.all_nodes = {} 21 | 22 | self.edge_groups = [] 23 | 24 | self.affected_nodes = {} 25 | self.affected_paths = [] 26 | self.affected_edges = [] 27 | 28 | def get_object_paths(self): 29 | paths = models.Path.objects.filter(graph__elements__nodes__contains=[{'data': {'object': { 'id': self.object_id, 'type': self.object_type}}}]) 30 | return paths 31 | 32 | def parse_contacts(self, contact_assignments): 33 | result = [] 34 | for assignment in contact_assignments: 35 | contact_dict = model_to_dict(assignment.contact) 36 | contact_dict['role'] = model_to_dict(assignment.role) 37 | 38 | result.append(contact_dict) 39 | return result 40 | 41 | def parse_path(self, path): 42 | result = { 43 | 'id': path.id, 44 | 'name': path.name, 45 | 'description': path.description, 46 | 'contacts': self.parse_contacts(path.contacts.all()), 47 | 'objects': self.clean_nodes(self.affected_nodes[path.id]), 48 | } 49 | return result 50 | 51 | def get_impact(self): 52 | paths = self.get_object_paths() 53 | 54 | for path in paths: 55 | self.affected_nodes[path.id] = [] 56 | 57 | self.all_nodes = {} 58 | self.source_nodes = {} 59 | 60 | self.parse_nodes(path) 61 | self.get_edge_groups(path) 62 | self.check_nodes(self.object_type, self.object_id, path, source=True) 63 | self.affected_paths.append(self.parse_path(path)) 64 | 65 | return self.affected_paths 66 | 67 | def clean_nodes(self, lst): 68 | lst = list({i['id']:i for i in reversed(lst)}.values()) 69 | for i, d in enumerate(lst): 70 | lst[i] = d['object'] 71 | if 'description' in d: 72 | lst[i]['description'] = d['description'] 73 | else: 74 | lst[i]['description'] = '' 75 | content_type = ContentType.objects.get_for_model(self.get_object(lst[i])) 76 | assignments = ContactAssignment.objects.filter(object_id=int(lst[i]['id']), content_type=content_type) 77 | lst[i]['contacts'] = self.parse_contacts(assignments) 78 | return lst 79 | 80 | def parse_nodes(self, path): 81 | for node in path.graph['elements']['nodes']: 82 | if node['data']['object']['id'] == self.object_id and node['data']['object']['type'] == self.object_type: 83 | self.source_nodes[node['data']['id']] = node 84 | self.all_nodes[node['data']['id']] = node 85 | 86 | return self.source_nodes 87 | 88 | def parse_edge_group(self, edge): 89 | edge_label = edge['data']['label'] 90 | edge_style = edge['data']['style'] 91 | edge_color = edge['data']['color'] 92 | 93 | return f'{edge_label}-{edge_color}-{edge_style}' 94 | 95 | def get_edge_groups(self, path): 96 | for edge in path.graph['elements']['edges']: 97 | if edge['data']['source'] in self.source_nodes: 98 | edge_group = self.parse_edge_group(edge) 99 | if edge_group not in self.edge_groups: 100 | self.edge_groups.append(edge_group) 101 | return self.edge_groups 102 | 103 | def check_nodes(self, type, id, path, source=False): 104 | for node in path.graph['elements']['nodes']: 105 | if node['data']['id'] in self.affected_nodes[path.id]: 106 | continue 107 | if node['data']['object']['id'] == id and node['data']['object']['type'] == type: 108 | if not source: 109 | node = self.set_node_direction(node, 'downstream') 110 | else: 111 | node['data']['object']['direction'] = [] 112 | self.affected_nodes[path.id].append(node['data']) 113 | self.check_edges(node['data']['id'], path, source) 114 | 115 | def check_edges(self, id, path, source): 116 | for edge in path.graph['elements']['edges']: 117 | if edge['data']['id'] in self.affected_edges: 118 | continue 119 | if edge['data']['source'] == id: 120 | edge_group = self.parse_edge_group(edge) 121 | if edge_group in self.edge_groups: 122 | self.affected_edges.append(edge['data']['id']) 123 | node = self.all_nodes[edge['data']['target']] 124 | self.check_nodes(node['data']['object']['type'], node['data']['object']['id'], path) 125 | if source and edge['data']['target'] == id: 126 | self.affected_edges.append(edge['data']['id']) 127 | node = self.all_nodes[edge['data']['source']] 128 | node = self.set_node_direction(node, 'upstream') 129 | self.affected_nodes[path.id].append(node['data']) 130 | 131 | def set_node_direction(self, node, direction): 132 | if 'direction' not in node['data']['object']: 133 | node['data']['object']['direction'] = [] 134 | 135 | if direction not in node['data']['object']['direction']: 136 | node['data']['object']['direction'].append(direction) 137 | 138 | return node 139 | 140 | def get_object(self, object): 141 | type = object['type'] 142 | id = object['id'] 143 | 144 | if type == 'dcim.devices': 145 | return Device.objects.get(pk=id) 146 | elif type == 'dcim.interfaces': 147 | return Interface.objects.get(pk=id) 148 | elif type == 'virtualization.interfaces': 149 | return VMInterface.objects.get(pk=id) 150 | elif type == 'circuits.circuits': 151 | return Circuit.objects.get(pk=id) 152 | elif type == 'ipam.vlans': 153 | return VLAN.objects.get(pk=id) 154 | elif type == 'dcim.racks': 155 | return Rack.objects.get(pk=id) 156 | elif type == 'dcim.regions': 157 | return Region.objects.get(pk=id) 158 | elif type == 'dcim.sites': 159 | return Site.objects.get(pk=id) 160 | elif type == 'tenancy.tenants': 161 | return Tenant.objects.get(pk=id) 162 | elif type == 'virtualization.virtual-machines': 163 | return VirtualMachine.objects.get(pk=id) 164 | 165 | 166 | -------------------------------------------------------------------------------- /netbox_path/templates/netbox_path/path.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | 3 | {% block head %} 4 | 5 | 6 | {% endblock head %} 7 | 8 | {% block content %} 9 |
10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |
20 |
21 |
22 | 23 | 24 | 25 | 26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | 34 | 46 |
47 | 48 | Select a Netbox object type to display its objects and filters. 49 | 50 |
51 | 52 | 58 |
59 |
60 | 61 | 62 | 63 | 64 |
65 | 66 | 69 | 72 |
73 | 74 |
75 | 76 |
77 | 78 | 79 |
80 | 81 | 85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 | 93 | {% comment %} Modal For Editing Node Attributes {% endcomment %} 94 | 115 | 116 | {% comment %} Modal For Editing Node Icon {% endcomment %} 117 | 141 |
142 | 143 | 144 | 145 | 146 | 147 | {% endblock content %} 148 | -------------------------------------------------------------------------------- /netbox_path/api/views.py: -------------------------------------------------------------------------------- 1 | from netbox.api.viewsets import NetBoxModelViewSet 2 | from rest_framework import viewsets 3 | from rest_framework.decorators import action 4 | from rest_framework.response import Response 5 | from .. import filtersets, models 6 | from .serializers import PathSerializer 7 | from .impact import ImpactAssessment 8 | 9 | class ImpactViewSet(viewsets.ViewSet): 10 | queryset = models.Path.objects.all() 11 | 12 | def list(self, request): 13 | impact = ImpactAssessment(request.GET.get('type'), request.GET.get('id')) 14 | result = impact.get_impact() 15 | return Response(result) 16 | 17 | class PathViewSet(NetBoxModelViewSet): 18 | queryset = models.Path.objects.prefetch_related('tags') 19 | serializer_class = PathSerializer 20 | 21 | # Image 22 | @action(detail=False, methods=["get", "post"], url_path=r'(?P[^/.]+)/image') 23 | def get_image(self, request, pk=None): 24 | print(request) 25 | if request.method == "POST": 26 | models.Path.objects.filter(pk=pk).update(image=request.data['image']) 27 | return Response(status=200) 28 | path = models.Path.objects.get(pk=pk) 29 | return Response(path.image) 30 | 31 | # Device objects 32 | @action(detail=False, methods=["get"], url_path=r'dcim/devices/(?P[^/.]+)') 33 | def device(self, request, pk=None): 34 | serializer = PathSerializer(filter_queryset('dcim.devices', pk), many=True, context={'request': request}) 35 | return Response(serializer.data) 36 | 37 | @action(detail=False, methods=["get"], url_path=r'dcim/devices') 38 | def devices(self, request): 39 | serializer = PathSerializer(filter_queryset('dcim.devices', None), many=True, context={'request': request}) 40 | return Response(serializer.data) 41 | 42 | # Interface objects 43 | @action(detail=False, methods=["get"], url_path=r'dcim/interfaces/(?P[^/.]+)') 44 | def interface(self, request, pk=None): 45 | serializer = PathSerializer(filter_queryset('dcim.interfaces', pk), many=True, context={'request': request}) 46 | return Response(serializer.data) 47 | 48 | @action(detail=False, methods=["get"], url_path=r'dcim/interfaces') 49 | def interfaces(self, request): 50 | serializer = PathSerializer(filter_queryset('dcim.interfaces', None), many=True, context={'request': request}) 51 | return Response(serializer.data) 52 | 53 | # VM Interface objects 54 | @action(detail=False, methods=["get"], url_path=r'virtualization/interfaces/(?P[^/.]+)') 55 | def vminterface(self, request, pk=None): 56 | serializer = PathSerializer(filter_queryset('virtualization.interfaces', pk), many=True, context={'request': request}) 57 | return Response(serializer.data) 58 | 59 | @action(detail=False, methods=["get"], url_path=r'virtualization/interfaces') 60 | def vminterfaces(self, request): 61 | serializer = PathSerializer(filter_queryset('virtualization.interfaces', None), many=True, context={'request': request}) 62 | return Response(serializer.data) 63 | 64 | # Circuit objects 65 | @action(detail=False, methods=["get"], url_path=r'circuits/circuits/(?P[^/.]+)') 66 | def circuit(self, request, pk=None): 67 | serializer = PathSerializer(filter_queryset('circuits.circuits', pk), many=True, context={'request': request}) 68 | return Response(serializer.data) 69 | 70 | @action(detail=False, methods=["get"], url_path=r'circuits/circuits') 71 | def circuits(self, request): 72 | serializer = PathSerializer(filter_queryset('circuits.circuits', None), many=True, context={'request': request}) 73 | return Response(serializer.data) 74 | 75 | # VLan objects 76 | 77 | @action(detail=False, methods=["get"], url_path=r'ipam/vlans/(?P[^/.]+)') 78 | def vlan(self, request, pk=None): 79 | serializer = PathSerializer(filter_queryset('ipam.vlans', pk), many=True, context={'request': request}) 80 | return Response(serializer.data) 81 | 82 | @action(detail=False, methods=["get"], url_path=r'ipam/vlans') 83 | def vlans(self, request): 84 | serializer = PathSerializer(filter_queryset('ipam.vlans', None), many=True, context={'request': request}) 85 | return Response(serializer.data) 86 | 87 | 88 | # Rack objects 89 | 90 | @action(detail=False, methods=["get"], url_path=r'dcim/racks/(?P[^/.]+)') 91 | def rack(self, request, pk=None): 92 | serializer = PathSerializer(filter_queryset('dcim.racks', pk), many=True, context={'request': request}) 93 | return Response(serializer.data) 94 | 95 | @action(detail=False, methods=["get"], url_path=r'dcim/racks') 96 | def racks(self, request): 97 | serializer = PathSerializer(filter_queryset('dcim.racks', None), many=True, context={'request': request}) 98 | return Response(serializer.data) 99 | 100 | 101 | # Region objects 102 | 103 | @action(detail=False, methods=["get"], url_path=r'dcim/regions/(?P[^/.]+)') 104 | def region(self, request, pk=None): 105 | serializer = PathSerializer(filter_queryset('dcim.regions', pk), many=True, context={'request': request}) 106 | return Response(serializer.data) 107 | 108 | @action(detail=False, methods=["get"], url_path=r'dcim/regions') 109 | def regions(self, request): 110 | serializer = PathSerializer(filter_queryset('dcim.regions', None), many=True, context={'request': request}) 111 | return Response(serializer.data) 112 | 113 | 114 | # Site objects 115 | 116 | @action(detail=False, methods=["get"], url_path=r'dcim/sites/(?P[^/.]+)') 117 | def site(self, request, pk=None): 118 | serializer = PathSerializer(filter_queryset('dcim.sites', pk), many=True, context={'request': request}) 119 | return Response(serializer.data) 120 | 121 | @action(detail=False, methods=["get"], url_path=r'dcim/sites') 122 | def sites(self, request): 123 | serializer = PathSerializer(filter_queryset('dcim.sites', None), many=True, context={'request': request}) 124 | return Response(serializer.data) 125 | 126 | # Tenant objects 127 | 128 | @action(detail=False, methods=["get"], url_path=r'tenancy/tenants/(?P[^/.]+)') 129 | def tenant(self, request, pk=None): 130 | serializer = PathSerializer(filter_queryset('tenancy.tenants', pk), many=True, context={'request': request}) 131 | return Response(serializer.data) 132 | 133 | @action(detail=False, methods=["get"], url_path=r'tenancy/tenants') 134 | def tenants(self, request): 135 | serializer = PathSerializer(filter_queryset('tenancy.tenants', None), many=True, context={'request': request}) 136 | return Response(serializer.data) 137 | 138 | # Virtual Machine objects 139 | 140 | @action(detail=False, methods=["get"], url_path=r'virtualization/virtual-machines/(?P[^/.]+)') 141 | def virtual_machine(self, request, pk=None): 142 | serializer = PathSerializer(filter_queryset('virtualization.virtual-machines', pk), many=True, context={'request': request}) 143 | return Response(serializer.data) 144 | 145 | @action(detail=False, methods=["get"], url_path=r'virtualization/virtual-machines/') 146 | def virtual_machines(self, request): 147 | serializer = PathSerializer(filter_queryset('virtualization.virtual-machines', None), many=True, context={'request': request}) 148 | return Response(serializer.data) 149 | 150 | def filter_queryset(type, pk): 151 | if pk is not None: 152 | return models.Path.objects.filter(graph__elements__nodes__contains=[{'data': {'object': { 'id': int(pk), 'type': type}}}]) 153 | else: 154 | return models.Path.objects.filter(graph__elements__nodes__contains=[{'data': {'object': {'type': type}}}]) -------------------------------------------------------------------------------- /netbox_path/views.py: -------------------------------------------------------------------------------- 1 | from netbox.views import generic 2 | from utilities.views import ViewTab, register_model_view 3 | from virtualization.models import VirtualMachine, VMInterface 4 | from tenancy.models import Tenant 5 | from dcim.models import Device, Rack, Region, Site, Interface 6 | from tenancy.views import ObjectContactsView 7 | from ipam.models import VLAN 8 | from circuits.models import Circuit 9 | from django.shortcuts import render 10 | from . import forms, models, tables 11 | 12 | class PathView(generic.ObjectView): 13 | queryset = models.Path.objects.all() 14 | 15 | class PathListView(generic.ObjectListView): 16 | queryset = models.Path.objects.all() 17 | table = tables.PathTable 18 | 19 | class PathEditView(generic.ObjectEditView): 20 | queryset = models.Path.objects.all() 21 | form = forms.PathForm 22 | 23 | class PathDeleteView(generic.ObjectDeleteView): 24 | queryset = models.Path.objects.all() 25 | 26 | @register_model_view(models.Path, 'contacts') 27 | class PathContactsView(ObjectContactsView): 28 | queryset = models.Path.objects.all() 29 | 30 | @register_model_view(Device, name='device_paths', path='paths') 31 | class DevicePaths(generic.ObjectView): 32 | template_name = 'netbox_path/device.html' 33 | tab = ViewTab( 34 | label='Paths', 35 | badge=lambda x: filter_queryset('dcim.devices', x.pk).count(), 36 | hide_if_empty=False 37 | ) 38 | 39 | def get(self, request, pk): 40 | return render(request, self.get_template_name(), {'object': Device.objects.get(pk=int(pk)), 'tab': self.tab, 'table': tables.PathTable(self.get_queryset())}) 41 | 42 | def get_queryset(self, *args, **kwargs): 43 | return filter_queryset('dcim.devices', self.kwargs['pk']) 44 | 45 | @register_model_view(Interface, name='interface_paths', path='paths') 46 | class InterfacePaths(generic.ObjectView): 47 | template_name = 'netbox_path/site.html' 48 | tab = ViewTab( 49 | label='Paths', 50 | badge=lambda x: filter_queryset('dcim.interfaces', x.pk).count(), 51 | hide_if_empty=False 52 | ) 53 | 54 | def get(self, request, pk): 55 | return render(request, self.get_template_name(), {'object': Interface.objects.get(pk=int(pk)), 'tab': self.tab, 'table': tables.PathTable(self.get_queryset())}) 56 | 57 | def get_queryset(self, *args, **kwargs): 58 | return filter_queryset('dcim.interfaces', self.kwargs['pk']) 59 | 60 | @register_model_view(VMInterface, name='vminterface_paths', path='paths') 61 | class VMInterfacePaths(generic.ObjectView): 62 | template_name = 'netbox_path/site.html' 63 | tab = ViewTab( 64 | label='Paths', 65 | badge=lambda x: filter_queryset('virtualization.interfaces', x.pk).count(), 66 | hide_if_empty=False 67 | ) 68 | 69 | def get(self, request, pk): 70 | return render(request, self.get_template_name(), {'object': VMInterface.objects.get(pk=int(pk)), 'tab': self.tab, 'table': tables.PathTable(self.get_queryset())}) 71 | 72 | def get_queryset(self, *args, **kwargs): 73 | return filter_queryset('virtualization.interfaces', self.kwargs['pk']) 74 | 75 | @register_model_view(Circuit, name='circuit_paths', path='paths') 76 | class CircuitPaths(generic.ObjectView): 77 | template_name = 'netbox_path/site.html' 78 | tab = ViewTab( 79 | label='Paths', 80 | badge=lambda x: filter_queryset('circuits.circuits', x.pk).count(), 81 | hide_if_empty=False 82 | ) 83 | 84 | def get(self, request, pk): 85 | return render(request, self.get_template_name(), {'object': Circuit.objects.get(pk=int(pk)), 'tab': self.tab, 'table': tables.PathTable(self.get_queryset())}) 86 | 87 | def get_queryset(self, *args, **kwargs): 88 | return filter_queryset('circuits.circuits', self.kwargs['pk']) 89 | 90 | @register_model_view(VLAN, name='vlan_paths', path='paths') 91 | class VLANPath(generic.ObjectView): 92 | template_name = 'netbox_path/vlan.html' 93 | tab = ViewTab( 94 | label='Paths', 95 | badge=lambda x: filter_queryset('ipam.vlans', x.pk).count(), 96 | hide_if_empty=False 97 | ) 98 | 99 | def get(self, request, pk): 100 | return render(request, self.get_template_name(), {'object': VLAN.objects.get(pk=int(pk)), 'tab': self.tab, 'table': tables.PathTable(self.get_queryset())}) 101 | 102 | def get_queryset(self, *args, **kwargs): 103 | return filter_queryset('ipam.vlans', self.kwargs['pk']) 104 | 105 | @register_model_view(Rack, name='rack_paths', path='paths') 106 | class RackPath(generic.ObjectView): 107 | template_name = 'netbox_path/rack.html' 108 | tab = ViewTab( 109 | label='Paths', 110 | badge=lambda x: filter_queryset('dcim.racks', x.pk).count(), 111 | hide_if_empty=False 112 | ) 113 | 114 | def get(self, request, pk): 115 | return render(request, self.get_template_name(), {'object': Rack.objects.get(pk=int(pk)), 'tab': self.tab, 'table': tables.PathTable(self.get_queryset())}) 116 | 117 | def get_queryset(self, *args, **kwargs): 118 | return filter_queryset('dcim.racks', self.kwargs['pk']) 119 | 120 | @register_model_view(Region, name='rack_paths', path='paths') 121 | class RegionPath(generic.ObjectView): 122 | template_name = 'netbox_path/region.html' 123 | tab = ViewTab( 124 | label='Paths', 125 | badge=lambda x: filter_queryset('dcim.regions', x.pk).count(), 126 | hide_if_empty=False 127 | ) 128 | 129 | def get(self, request, pk): 130 | return render(request, self.get_template_name(), {'object': Region.objects.get(pk=int(pk)), 'tab': self.tab, 'table': tables.PathTable(self.get_queryset())}) 131 | 132 | def get_queryset(self, *args, **kwargs): 133 | return filter_queryset('dcim.regions', self.kwargs['pk']) 134 | 135 | @register_model_view(Site, name='site_paths', path='paths') 136 | class SitePath(generic.ObjectView): 137 | template_name = 'netbox_path/site.html' 138 | tab = ViewTab( 139 | label='Paths', 140 | badge=lambda x: filter_queryset('dcim.sites', x.pk).count(), 141 | hide_if_empty=False 142 | ) 143 | 144 | def get(self, request, pk): 145 | return render(request, self.get_template_name(), {'object': Site.objects.get(pk=int(pk)), 'tab': self.tab, 'table': tables.PathTable(self.get_queryset())}) 146 | 147 | def get_queryset(self, *args, **kwargs): 148 | return filter_queryset('dcim.sites', self.kwargs['pk']) 149 | 150 | @register_model_view(Tenant, name='tenant_paths', path='paths') 151 | class TenantPath(generic.ObjectView): 152 | template_name = 'netbox_path/site.html' 153 | tab = ViewTab( 154 | label='Paths', 155 | badge=lambda x: filter_queryset('tenancy.tenants', x.pk).count(), 156 | hide_if_empty=False 157 | ) 158 | 159 | def get(self, request, pk): 160 | return render(request, self.get_template_name(), {'object': Tenant.objects.get(pk=int(pk)), 'tab': self.tab, 'table': tables.PathTable(self.get_queryset())}) 161 | 162 | def get_queryset(self, *args, **kwargs): 163 | return filter_queryset('tenancy.tenants', self.kwargs['pk']) 164 | 165 | @register_model_view(VirtualMachine, name='virtualmachine_paths', path='paths') 166 | class VirtualMachinePath(generic.ObjectView): 167 | template_name = 'netbox_path/virtualmachine.html' 168 | tab = ViewTab( 169 | label='Paths', 170 | badge=lambda x: filter_queryset('virtualization.virtual-machines', x.pk).count(), 171 | hide_if_empty=False 172 | ) 173 | 174 | def get(self, request, pk): 175 | return render(request, self.get_template_name(), {'object': VirtualMachine.objects.get(pk=int(pk)), 'tab': self.tab, 'table': tables.PathTable(self.get_queryset())}) 176 | 177 | def get_queryset(self, *args, **kwargs): 178 | return filter_queryset('virtualization.virtual-machines', self.kwargs['pk']) 179 | 180 | 181 | def filter_queryset(type, pk): 182 | return models.Path.objects.filter(graph__elements__nodes__contains=[{'data': {'object': { 'id': int(pk), 'type': type}}}]) 183 | -------------------------------------------------------------------------------- /frontend/selector.js: -------------------------------------------------------------------------------- 1 | import './main.js' 2 | import TomSelect from 'tom-select'; 3 | 4 | var objectProperties, netboxObjectSelect, queryVal, selectedObject; 5 | var queryUrl = '/api/dcim/devices/?limit=100' 6 | var qureyFilters = [] 7 | var filters = [] 8 | 9 | netboxObjectSelect = new TomSelect('#netbox-object-select', { 10 | valueField: 'id', 11 | labelField: 'display', 12 | searchField: 'display', 13 | options: [], 14 | preload: true, 15 | create: false, 16 | load: function (query, callback) { 17 | fetch(buildQuery()) 18 | .then(response => response.json()) 19 | .then(data => { 20 | var options = data.results 21 | callback(options) 22 | }).catch(error => { 23 | console.log(error) 24 | }) 25 | }, 26 | render: { 27 | option: function (item, escape) { 28 | var objectDetails = '' 29 | for (var key in item) { 30 | if (item[key] !== null && typeof item[key] === 'object' && "url" in item[key]) { 31 | objectDetails += `
${escape(formatKey(key))}: ${item[key].display}
` 32 | } 33 | } 34 | return `
35 |
36 |

${escape(item.display)}

37 |
38 |
39 | ${objectDetails} 40 |
41 |
42 |
43 |
`; 44 | }, 45 | }, 46 | onChange: function (value) { 47 | updateSelectedObject(value) 48 | }, 49 | onType: function (str) { 50 | queryVal = str 51 | } 52 | }); 53 | 54 | // Update the selected object when the select changes 55 | const updateQueryBase = (newBase) => { 56 | queryUrl = `/api/${newBase}/?limit=100` 57 | } 58 | 59 | // Update the query url with the current filters 60 | const updateQueryFilters = (object, filterValue) => { 61 | // If the filterValue is empty, remove the filter from the query 62 | if (filterValue === '') { 63 | qureyFilters = qureyFilters.filter(filter => filter.object !== object) 64 | } else { 65 | // We do manual object name comparisons here because sometimes the object name is different than the query name 66 | if (object === 'device_role') { 67 | object = 'role' 68 | } 69 | // If the object is already in the query, update the filter value 70 | var filter = qureyFilters.find(filter => filter.object === object) 71 | if (filter) { 72 | filter.filterValue = filterValue 73 | } else { 74 | qureyFilters.push({ object: object, filterValue: filterValue }) 75 | } 76 | } 77 | updateObjectSelect() 78 | } 79 | 80 | // Reset the query filters array 81 | const resetQueryFilters = () => { 82 | qureyFilters = [] 83 | } 84 | 85 | // Build the query URL with the current filters 86 | const buildQuery = () => { 87 | var query = queryUrl 88 | if (qureyFilters.length > 0) { 89 | query += '&' 90 | qureyFilters.forEach(filter => { 91 | query += `${filter.object}_id=${filter.filterValue}&` 92 | // If it is the last filter, remove the trailing & 93 | if (filter === qureyFilters[qureyFilters.length - 1]) { 94 | query = query.slice(0, -1) 95 | } 96 | }) 97 | } 98 | // If the user typed in a search value, add it to the query 99 | if (queryVal) { 100 | query += `&q=${queryVal}` 101 | } 102 | return query 103 | } 104 | 105 | // Update the object select options 106 | const updateObjectSelect = () => { 107 | queryVal = '' 108 | netboxObjectSelect.clear(true) 109 | netboxObjectSelect.clearOptions() 110 | netboxObjectSelect.load(buildQuery()) 111 | netboxObjectSelect.refreshOptions(false) 112 | } 113 | 114 | // Update the selected object with the given value from the object select 115 | const updateSelectedObject = (object) => { 116 | selectedObject = object 117 | } 118 | 119 | // Function to format the key for display 120 | const formatKey = (key) => { 121 | var keyParts = key.split('_') 122 | for (var i = 0; i < keyParts.length; i++) { 123 | keyParts[i] = keyParts[i].charAt(0).toUpperCase() + keyParts[i].slice(1) 124 | } 125 | return keyParts.join(' ') 126 | } 127 | 128 | // Function to create the new select element for the object type 129 | const createFilterSelectElement = (elementId, key) => { 130 | // Create a label for the select element 131 | var deviceTypeLabel = document.createElement('label') 132 | deviceTypeLabel.setAttribute('for', elementId) 133 | deviceTypeLabel.innerHTML = `Filter by: ${formatKey(key)}` 134 | document.getElementById('netbox-object-select-filter').appendChild(deviceTypeLabel) 135 | 136 | // Create a new select element for the device type 137 | var deviceTypeSelect = document.createElement('select') 138 | deviceTypeSelect.setAttribute('id', elementId) 139 | 140 | // Append the select element to the page 141 | document.getElementById('netbox-object-select-filter').appendChild(deviceTypeSelect) 142 | } 143 | 144 | // Function to create the filter select elements for the selected object type 145 | const createFilterSelect = () => { 146 | if (objectProperties) { 147 | var object = objectProperties 148 | } else { 149 | return 150 | } 151 | // Make a new filter select element for each property 152 | for (var key in object) { 153 | // Check if there is a URL in the object 154 | if (object[key] !== null && typeof object[key] === 'object' && "url" in object[key]) { 155 | 156 | // Get the url endpoint for the device type after the slash 157 | var splitUrl = object[key].url.split('/') 158 | var deviceTypeUrl = `/${splitUrl[3]}/${splitUrl[4]}/${splitUrl[5]}` 159 | 160 | // Generate the select box for the device type 161 | var deviceTypeSelectId = `${key}-type-filter` 162 | createFilterSelectElement(deviceTypeSelectId, key) 163 | 164 | // Initialize the select box for the filter type 165 | new TomSelect(`#${deviceTypeSelectId}`, { 166 | valueField: 'id', 167 | labelField: 'display', 168 | searchField: 'display', 169 | options: [], 170 | preload: true, 171 | create: false, 172 | load: function (query, callback) { 173 | var inputId = this.inputId 174 | var url = filters.filter(function (element) { return element.domId == inputId; })[0].url 175 | fetch(`${url}/?limit=100&q=${query}`) 176 | .then(response => response.json()) 177 | .then(data => { 178 | var options = data.results 179 | callback(options) 180 | }).catch(error => { 181 | console.log(error) 182 | }) 183 | }, 184 | render: { 185 | option: function (item, escape) { 186 | var objectDetails = '' 187 | for (var key in item) { 188 | if (item[key] !== null && typeof item[key] === 'object' && "url" in item[key]) { 189 | objectDetails += `
${escape(formatKey(key))}: ${item[key].display}
` 190 | } 191 | } 192 | return `
193 |
194 |

${escape(item.display)}

195 |
196 |
197 | ${objectDetails} 198 |
199 |
200 |
201 |
`; 202 | }, 203 | }, 204 | onChange: function (e) { 205 | var inputId = this.inputId 206 | var key = filters.filter(function (element) { return element.domId == inputId; })[0].key 207 | var filterValue = e 208 | updateQueryFilters(key, filterValue) 209 | } 210 | }); 211 | 212 | // Add the filter to the list of filters 213 | filters.push({ domId: deviceTypeSelectId, url: deviceTypeUrl, key: key }) 214 | } 215 | } 216 | // If there are no filters, print a message to the user 217 | if (filters.length === 0) { 218 | var noFilters = document.createElement('p') 219 | noFilters.innerHTML = 'No filters available' 220 | document.getElementById('netbox-object-select-filter').appendChild(noFilters) 221 | } 222 | } 223 | 224 | // Change th list of filters when the object type is changed 225 | const changeFilters = () => { 226 | var objectTypeSelect = document.getElementById('netbox-object-type-select') 227 | var objectType = objectTypeSelect.options[objectTypeSelect.selectedIndex].getAttribute('value') 228 | 229 | // Set the label of the device select to the name of the selected object type 230 | var objectLabel = document.getElementById('netbox-object-select-label') 231 | var objectTypeLabel = objectTypeSelect.options[objectTypeSelect.selectedIndex].innerHTML 232 | 233 | // Drop the s from the end of the object type name 234 | if (objectTypeLabel.endsWith('s')) { 235 | objectTypeLabel = objectTypeLabel.slice(0, -1) 236 | } 237 | objectLabel.innerHTML = `Add ${objectTypeLabel}` 238 | 239 | updateQueryBase(objectType) 240 | updateObjectSelect() 241 | resetQueryFilters() 242 | 243 | filters = [] 244 | 245 | // Clear the netbox-object-select-filter div 246 | var objectSelectFilter = document.getElementById('netbox-object-select-filter') 247 | objectSelectFilter.innerHTML = '' 248 | 249 | // Get the first object of the selected type from the API 250 | 251 | fetch(`/api/${objectType}/?limit=1`) 252 | .then(response => response.json()) 253 | .then(data => { 254 | objectProperties = data.results[0] 255 | }) 256 | .then(() => { 257 | createFilterSelect() 258 | }) 259 | } 260 | 261 | export { selectedObject, changeFilters, formatKey } -------------------------------------------------------------------------------- /frontend/style.css: -------------------------------------------------------------------------------- 1 | #nbp-cy { 2 | border: 1px solid gray; 3 | width: 100%; 4 | height: 70vh; 5 | } 6 | 7 | #nbp-navigator { 8 | min-height: 25%; 9 | min-width: 25%; 10 | bottom: 0; 11 | right: 0; 12 | position: absolute; 13 | overflow: hidden; 14 | z-index: 999999; 15 | background: white !important; 16 | } 17 | 18 | #nbp-navigator img:not([src]) { 19 | display: none; 20 | } 21 | 22 | .cytoscape-navigatorOverlay { 23 | border: 3px solid gray; 24 | background: transparent; 25 | cursor: move; 26 | } 27 | 28 | 29 | .cytoscape-navigatorOverlay { 30 | border: 2px solid #2b689c; 31 | background: transparent; 32 | } 33 | 34 | .cytoscape-navigator.mouseover-view .cytoscape-navigatorOverlay { 35 | background-color: rgba(18, 65, 145, 0.3); 36 | } 37 | 38 | .node { 39 | display: inline-flex; 40 | flex-direction: column; 41 | align-items: center; 42 | } 43 | 44 | .popover { 45 | white-space: pre-wrap; 46 | } 47 | 48 | .node-selected { 49 | background: #f00 !important; 50 | border: 1px solid #f00 !important; 51 | color: white; 52 | } 53 | 54 | .node-icon { 55 | display: inline-flex; 56 | flex-direction: column; 57 | justify-content: center; 58 | align-items: center; 59 | border-radius: 3px; 60 | width: 40px; 61 | height: 40px; 62 | background: #ffffff; 63 | margin-top: 15px; 64 | border: 1px solid gray; 65 | } 66 | 67 | .node:hover > .node-icon { 68 | background: #f0f0f0; 69 | } 70 | 71 | .node-label { 72 | font-size: 6px; 73 | max-width: 75px; 74 | white-space: nowrap; 75 | color: black; 76 | text-transform: uppercase; 77 | margin-top: 6px; 78 | } 79 | 80 | /* Tom-Select Styling */ 81 | 82 | /** 83 | * Tom Select bootstrap 5 84 | */ 85 | /** 86 | * tom-select.css (v2.1.0) 87 | * Copyright (c) contributors 88 | * 89 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this 90 | * file except in compliance with the License. You may obtain a copy of the License at: 91 | * http://www.apache.org/licenses/LICENSE-2.0 92 | * 93 | * Unless required by applicable law or agreed to in writing, software distributed under 94 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 95 | * ANY KIND, either express or implied. See the License for the specific language 96 | * governing permissions and limitations under the License. 97 | * 98 | */ 99 | .ts-wrapper.single .ts-control, .ts-wrapper.single .ts-control input { 100 | cursor: pointer; 101 | } 102 | 103 | .ts-wrapper.plugin-drag_drop.multi > .ts-control > div.ui-sortable-placeholder { 104 | visibility: visible !important; 105 | background: #f2f2f2 !important; 106 | background: rgba(0, 0, 0, 0.06) !important; 107 | border: 0 none !important; 108 | box-shadow: inset 0 0 12px 4px #fff; 109 | } 110 | .ts-wrapper.plugin-drag_drop .ui-sortable-placeholder::after { 111 | content: "!"; 112 | visibility: hidden; 113 | } 114 | .ts-wrapper.plugin-drag_drop .ui-sortable-helper { 115 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); 116 | } 117 | 118 | .plugin-checkbox_options .option input { 119 | margin-right: 0.5rem; 120 | } 121 | 122 | .plugin-clear_button .ts-control { 123 | padding-right: calc(1em + (3 * 5px)) !important; 124 | } 125 | .plugin-clear_button .clear-button { 126 | opacity: 0; 127 | position: absolute; 128 | top: 0.375rem; 129 | right: calc(0.75rem - 5px); 130 | margin-right: 0 !important; 131 | background: transparent !important; 132 | transition: opacity 0.5s; 133 | cursor: pointer; 134 | } 135 | .plugin-clear_button.single .clear-button { 136 | right: calc(0.75rem - 5px + 2rem); 137 | } 138 | .plugin-clear_button.focus.has-items .clear-button, .plugin-clear_button:not(.disabled):hover.has-items .clear-button { 139 | opacity: 1; 140 | } 141 | 142 | .ts-wrapper .dropdown-header { 143 | position: relative; 144 | padding: 6px 0.75rem; 145 | border-bottom: 1px solid #d0d0d0; 146 | background: #f8f8f8; 147 | border-radius: 0.25rem 0.25rem 0 0; 148 | } 149 | .ts-wrapper .dropdown-header-close { 150 | position: absolute; 151 | right: 0.75rem; 152 | top: 50%; 153 | color: #343a40; 154 | opacity: 0.4; 155 | margin-top: -12px; 156 | line-height: 20px; 157 | font-size: 20px !important; 158 | } 159 | .ts-wrapper .dropdown-header-close:hover { 160 | color: black; 161 | } 162 | 163 | .plugin-dropdown_input.focus.dropdown-active .ts-control { 164 | box-shadow: none; 165 | border: 1px solid #ced4da; 166 | box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.075); 167 | } 168 | .plugin-dropdown_input .dropdown-input { 169 | border: 1px solid #d0d0d0; 170 | border-width: 0 0 1px 0; 171 | display: block; 172 | padding: 0.375rem 0.75rem; 173 | box-shadow: none; 174 | width: 100%; 175 | background: transparent; 176 | } 177 | .plugin-dropdown_input.focus .ts-dropdown .dropdown-input { 178 | border-color: #86b7fe; 179 | outline: 0; 180 | box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); 181 | } 182 | .plugin-dropdown_input .items-placeholder { 183 | border: 0 none !important; 184 | box-shadow: none !important; 185 | width: 100%; 186 | } 187 | .plugin-dropdown_input.has-items .items-placeholder, .plugin-dropdown_input.dropdown-active .items-placeholder { 188 | display: none !important; 189 | } 190 | 191 | .ts-wrapper.plugin-input_autogrow.has-items .ts-control > input { 192 | min-width: 0; 193 | } 194 | .ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input { 195 | flex: none; 196 | min-width: 4px; 197 | } 198 | .ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input::-webkit-input-placeholder { 199 | color: transparent; 200 | } 201 | .ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input::-ms-input-placeholder { 202 | color: transparent; 203 | } 204 | .ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input::placeholder { 205 | color: transparent; 206 | } 207 | 208 | .ts-dropdown.plugin-optgroup_columns .ts-dropdown-content { 209 | display: flex; 210 | } 211 | .ts-dropdown.plugin-optgroup_columns .optgroup { 212 | border-right: 1px solid #f2f2f2; 213 | border-top: 0 none; 214 | flex-grow: 1; 215 | flex-basis: 0; 216 | min-width: 0; 217 | } 218 | .ts-dropdown.plugin-optgroup_columns .optgroup:last-child { 219 | border-right: 0 none; 220 | } 221 | .ts-dropdown.plugin-optgroup_columns .optgroup:before { 222 | display: none; 223 | } 224 | .ts-dropdown.plugin-optgroup_columns .optgroup-header { 225 | border-top: 0 none; 226 | } 227 | 228 | .ts-wrapper.plugin-remove_button .item { 229 | display: inline-flex; 230 | align-items: center; 231 | padding-right: 0 !important; 232 | } 233 | .ts-wrapper.plugin-remove_button .item .remove { 234 | color: inherit; 235 | text-decoration: none; 236 | vertical-align: middle; 237 | display: inline-block; 238 | padding: 0 5px; 239 | border-left: 1px solid #dee2e6; 240 | border-radius: 0 2px 2px 0; 241 | box-sizing: border-box; 242 | margin-left: 5px; 243 | } 244 | .ts-wrapper.plugin-remove_button .item .remove:hover { 245 | background: rgba(0, 0, 0, 0.05); 246 | } 247 | .ts-wrapper.plugin-remove_button .item.active .remove { 248 | border-left-color: rgba(0, 0, 0, 0); 249 | } 250 | .ts-wrapper.plugin-remove_button.disabled .item .remove:hover { 251 | background: none; 252 | } 253 | .ts-wrapper.plugin-remove_button.disabled .item .remove { 254 | border-left-color: white; 255 | } 256 | .ts-wrapper.plugin-remove_button .remove-single { 257 | position: absolute; 258 | right: 0; 259 | top: 0; 260 | font-size: 23px; 261 | } 262 | 263 | .ts-wrapper { 264 | position: relative; 265 | } 266 | 267 | .ts-dropdown, 268 | .ts-control, 269 | .ts-control input { 270 | color: #343a40; 271 | font-family: inherit; 272 | font-size: inherit; 273 | line-height: 1.5; 274 | font-smoothing: inherit; 275 | } 276 | 277 | .ts-control, 278 | .ts-wrapper.single.input-active .ts-control { 279 | background: #fff; 280 | cursor: text; 281 | } 282 | 283 | .ts-control { 284 | border: 1px solid #ced4da; 285 | padding: 0.375rem 0.75rem; 286 | width: 100%; 287 | overflow: hidden; 288 | position: relative; 289 | z-index: 1; 290 | box-sizing: border-box; 291 | box-shadow: none; 292 | border-radius: 0.25rem; 293 | display: flex; 294 | flex-wrap: wrap; 295 | } 296 | .ts-wrapper.multi.has-items .ts-control { 297 | padding: calc( 0.375rem - 1px - 0px) 0.75rem calc( 0.375rem - 1px - 3px - 0px); 298 | } 299 | .full .ts-control { 300 | background-color: #fff; 301 | } 302 | .disabled .ts-control, .disabled .ts-control * { 303 | cursor: default !important; 304 | } 305 | .focus .ts-control { 306 | box-shadow: none; 307 | } 308 | .ts-control > * { 309 | vertical-align: baseline; 310 | display: inline-block; 311 | } 312 | .ts-wrapper.multi .ts-control > div { 313 | cursor: pointer; 314 | margin: 0 3px 3px 0; 315 | padding: 1px 5px; 316 | background: #efefef; 317 | color: #343a40; 318 | border: 0px solid #dee2e6; 319 | } 320 | .ts-wrapper.multi .ts-control > div.active { 321 | background: #0d6efd; 322 | color: #fff; 323 | border: 0px solid rgba(0, 0, 0, 0); 324 | } 325 | .ts-wrapper.multi.disabled .ts-control > div, .ts-wrapper.multi.disabled .ts-control > div.active { 326 | color: #878787; 327 | background: white; 328 | border: 0px solid white; 329 | } 330 | .ts-control > input { 331 | flex: 1 1 auto; 332 | min-width: 7rem; 333 | display: inline-block !important; 334 | padding: 0 !important; 335 | min-height: 0 !important; 336 | max-height: none !important; 337 | max-width: 100% !important; 338 | margin: 0 !important; 339 | text-indent: 0 !important; 340 | border: 0 none !important; 341 | background: none !important; 342 | line-height: inherit !important; 343 | -webkit-user-select: auto !important; 344 | -moz-user-select: auto !important; 345 | -ms-user-select: auto !important; 346 | user-select: auto !important; 347 | box-shadow: none !important; 348 | } 349 | .ts-control > input::-ms-clear { 350 | display: none; 351 | } 352 | .ts-control > input:focus { 353 | outline: none !important; 354 | } 355 | .has-items .ts-control > input { 356 | margin: 0px 4px !important; 357 | } 358 | .ts-control.rtl { 359 | text-align: right; 360 | } 361 | .ts-control.rtl.single .ts-control:after { 362 | left: calc(0.75rem + 5px); 363 | right: auto; 364 | } 365 | .ts-control.rtl .ts-control > input { 366 | margin: 0px 4px 0px -2px !important; 367 | } 368 | .disabled .ts-control { 369 | opacity: 0.5; 370 | background-color: #e9ecef; 371 | } 372 | .input-hidden .ts-control > input { 373 | opacity: 0; 374 | position: absolute; 375 | left: -10000px; 376 | } 377 | 378 | .ts-dropdown { 379 | position: absolute; 380 | top: 100%; 381 | left: 0; 382 | width: 100%; 383 | z-index: 10; 384 | border: 1px solid #d0d0d0; 385 | background: #fff; 386 | margin: 0.25rem 0 0 0; 387 | border-top: 0 none; 388 | box-sizing: border-box; 389 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 390 | border-radius: 0 0 0.25rem 0.25rem; 391 | } 392 | .ts-dropdown [data-selectable] { 393 | cursor: pointer; 394 | overflow: hidden; 395 | } 396 | .ts-dropdown [data-selectable] .highlight { 397 | background: rgba(255, 237, 40, 0.4); 398 | border-radius: 1px; 399 | } 400 | .ts-dropdown .option, 401 | .ts-dropdown .optgroup-header, 402 | .ts-dropdown .no-results, 403 | .ts-dropdown .create { 404 | padding: 3px 0.75rem; 405 | } 406 | .ts-dropdown .option, .ts-dropdown [data-disabled], .ts-dropdown [data-disabled] [data-selectable].option { 407 | cursor: inherit; 408 | opacity: 0.5; 409 | } 410 | .ts-dropdown [data-selectable].option { 411 | opacity: 1; 412 | cursor: pointer; 413 | } 414 | .ts-dropdown .optgroup:first-child .optgroup-header { 415 | border-top: 0 none; 416 | } 417 | .ts-dropdown .optgroup-header { 418 | color: #6c757d; 419 | background: #fff; 420 | cursor: default; 421 | } 422 | .ts-dropdown .active { 423 | background-color: #e9ecef; 424 | color: #1e2125; 425 | } 426 | .ts-dropdown .active.create { 427 | color: #1e2125; 428 | } 429 | .ts-dropdown .create { 430 | color: rgba(52, 58, 64, 0.5); 431 | } 432 | .ts-dropdown .spinner { 433 | display: inline-block; 434 | width: 30px; 435 | height: 30px; 436 | margin: 3px 0.75rem; 437 | } 438 | .ts-dropdown .spinner:after { 439 | content: " "; 440 | display: block; 441 | width: 24px; 442 | height: 24px; 443 | margin: 3px; 444 | border-radius: 50%; 445 | border: 5px solid #d0d0d0; 446 | border-color: #d0d0d0 transparent #d0d0d0 transparent; 447 | animation: lds-dual-ring 1.2s linear infinite; 448 | } 449 | @keyframes lds-dual-ring { 450 | 0% { 451 | transform: rotate(0deg); 452 | } 453 | 100% { 454 | transform: rotate(360deg); 455 | } 456 | } 457 | 458 | .ts-dropdown-content { 459 | overflow-y: auto; 460 | overflow-x: hidden; 461 | max-height: 400px; 462 | overflow-scrolling: touch; 463 | scroll-behavior: smooth; 464 | } 465 | 466 | .ts-hidden-accessible { 467 | border: 0 !important; 468 | clip: rect(0 0 0 0) !important; 469 | -webkit-clip-path: inset(50%) !important; 470 | clip-path: inset(50%) !important; 471 | overflow: hidden !important; 472 | padding: 0 !important; 473 | position: absolute !important; 474 | width: 1px !important; 475 | white-space: nowrap !important; 476 | } 477 | 478 | .ts-wrapper.form-control, 479 | .ts-wrapper.form-select { 480 | padding: 0 !important; 481 | height: auto; 482 | box-shadow: none; 483 | display: flex; 484 | } 485 | 486 | .ts-dropdown, 487 | .ts-dropdown.form-control, 488 | .ts-dropdown.form-select { 489 | height: auto; 490 | padding: 0; 491 | z-index: 1000; 492 | background: #fff; 493 | border: 1px solid rgba(0, 0, 0, 0.15); 494 | border-radius: 0.25rem; 495 | box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); 496 | } 497 | 498 | .ts-dropdown .optgroup-header { 499 | font-size: 0.875rem; 500 | line-height: 1.5; 501 | } 502 | .ts-dropdown .optgroup:first-child:before { 503 | display: none; 504 | } 505 | .ts-dropdown .optgroup:before { 506 | content: " "; 507 | display: block; 508 | height: 0; 509 | margin: 0.5rem 0; 510 | overflow: hidden; 511 | border-top: 1px solid rgba(0, 0, 0, 0.15); 512 | margin-left: -0.75rem; 513 | margin-right: -0.75rem; 514 | } 515 | .ts-dropdown .create { 516 | padding-left: 0.75rem; 517 | } 518 | 519 | .ts-dropdown-content { 520 | padding: 5px 0; 521 | } 522 | 523 | .ts-control { 524 | transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 525 | display: flex; 526 | align-items: center; 527 | } 528 | @media (prefers-reduced-motion: reduce) { 529 | .ts-control { 530 | transition: none; 531 | } 532 | } 533 | .ts-control.dropdown -active { 534 | border-radius: 0.25rem; 535 | } 536 | .focus .ts-control { 537 | border-color: #86b7fe; 538 | outline: 0; 539 | box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); 540 | } 541 | .ts-control .item { 542 | display: flex; 543 | align-items: center; 544 | } 545 | 546 | .ts-wrapper.is-invalid, 547 | .was-validated .invalid, 548 | .was-validated :invalid + .ts-wrapper { 549 | border-color: #dc3545; 550 | } 551 | .ts-wrapper.is-invalid:not(.single), 552 | .was-validated .invalid:not(.single), 553 | .was-validated :invalid + .ts-wrapper:not(.single) { 554 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e"); 555 | background-position: right calc(0.375em + 0.1875rem) center; 556 | background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); 557 | background-repeat: no-repeat; 558 | } 559 | .ts-wrapper.is-invalid.single, 560 | .was-validated .invalid.single, 561 | .was-validated :invalid + .ts-wrapper.single { 562 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"), url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e"); 563 | background-position: right 0.75rem center, center right 2.25rem; 564 | background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); 565 | background-repeat: no-repeat; 566 | } 567 | .ts-wrapper.is-invalid.focus .ts-control, 568 | .was-validated .invalid.focus .ts-control, 569 | .was-validated :invalid + .ts-wrapper.focus .ts-control { 570 | border-color: #dc3545; 571 | box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.25); 572 | } 573 | 574 | .ts-wrapper.is-valid, 575 | .was-validated .valid, 576 | .was-validated :valid + .ts-wrapper { 577 | border-color: #198754; 578 | } 579 | .ts-wrapper.is-valid:not(.single), 580 | .was-validated .valid:not(.single), 581 | .was-validated :valid + .ts-wrapper:not(.single) { 582 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); 583 | background-position: right calc(0.375em + 0.1875rem) center; 584 | background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); 585 | background-repeat: no-repeat; 586 | } 587 | .ts-wrapper.is-valid.single, 588 | .was-validated .valid.single, 589 | .was-validated :valid + .ts-wrapper.single { 590 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"), url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); 591 | background-position: right 0.75rem center, center right 2.25rem; 592 | background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); 593 | background-repeat: no-repeat; 594 | } 595 | .ts-wrapper.is-valid.focus .ts-control, 596 | .was-validated .valid.focus .ts-control, 597 | .was-validated :valid + .ts-wrapper.focus .ts-control { 598 | border-color: #198754; 599 | box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.25); 600 | } 601 | 602 | .ts-wrapper { 603 | min-height: calc(1.5em + 0.75rem + 2px); 604 | display: flex; 605 | } 606 | .input-group-sm > .ts-wrapper, .ts-wrapper.form-select-sm, .ts-wrapper.form-control-sm { 607 | min-height: calc(1.5em + 0.5rem + 2px); 608 | } 609 | .input-group-sm > .ts-wrapper .ts-control, .ts-wrapper.form-select-sm .ts-control, .ts-wrapper.form-control-sm .ts-control { 610 | padding: 0 0.75rem; 611 | border-radius: 0.2rem; 612 | font-size: 0.875rem; 613 | } 614 | .input-group-sm > .ts-wrapper.has-items .ts-control, .ts-wrapper.form-select-sm.has-items .ts-control, .ts-wrapper.form-control-sm.has-items .ts-control { 615 | font-size: 0.875rem; 616 | padding-bottom: 0; 617 | } 618 | .input-group-sm > .ts-wrapper.multi.has-items .ts-control, .ts-wrapper.form-select-sm.multi.has-items .ts-control, .ts-wrapper.form-control-sm.multi.has-items .ts-control { 619 | padding-top: calc((calc(1.5em + 0.5rem + 2px) - (1.5 * 0.875rem) - 4px) / 2) !important; 620 | } 621 | .ts-wrapper.multi.has-items .ts-control { 622 | padding-left: calc(0.75rem - 5px); 623 | padding-right: calc(0.75rem - 5px); 624 | } 625 | .ts-wrapper.multi .ts-control > div { 626 | border-radius: calc(0.25rem - 1px); 627 | } 628 | .input-group-lg > .ts-wrapper, .ts-wrapper.form-control-lg, .ts-wrapper.form-select-lg { 629 | min-height: calc(1.5em + 1rem + 2px); 630 | } 631 | .input-group-lg > .ts-wrapper .ts-control, .ts-wrapper.form-control-lg .ts-control, .ts-wrapper.form-select-lg .ts-control { 632 | border-radius: 0.3rem; 633 | font-size: 1.25rem; 634 | } 635 | 636 | .ts-wrapper:not(.form-control):not(.form-select) { 637 | padding: 0; 638 | border: none; 639 | height: auto; 640 | box-shadow: none; 641 | background: none; 642 | } 643 | .ts-wrapper:not(.form-control):not(.form-select).single .ts-control { 644 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"); 645 | background-repeat: no-repeat; 646 | background-position: right 0.75rem center; 647 | background-size: 16px 12px; 648 | padding-right: 2rem; 649 | } 650 | 651 | .ts-wrapper.form-control .ts-control, .ts-wrapper.form-control.single.input-active .ts-control, 652 | .ts-wrapper.form-select .ts-control, 653 | .ts-wrapper.form-select.single.input-active .ts-control { 654 | border: none !important; 655 | background: transparent !important; 656 | } 657 | 658 | .input-group > .ts-wrapper { 659 | flex-grow: 1; 660 | } 661 | .input-group > .ts-wrapper:not(:nth-child(2)) > .ts-control { 662 | border-top-left-radius: 0; 663 | border-bottom-left-radius: 0; 664 | } 665 | .input-group > .ts-wrapper:not(:last-child) > .ts-control { 666 | border-top-right-radius: 0; 667 | border-bottom-right-radius: 0; 668 | } 669 | /*# sourceMappingURL=tom-select.bootstrap5.css.map */ -------------------------------------------------------------------------------- /frontend/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "frontend", 9 | "version": "0.0.0", 10 | "dependencies": { 11 | "cytoscape": "^3.22.1", 12 | "cytoscape-navigator": "^2.0.2", 13 | "cytoscape-node-html-label": "^1.2.2", 14 | "cytoscape-popper": "^2.0.0", 15 | "html2canvas": "^1.4.1", 16 | "microplugin": "^0.0.3", 17 | "sifter": "^0.0.5", 18 | "tippy.js": "^6.3.7", 19 | "tom-select": "^2.1.0" 20 | }, 21 | "devDependencies": { 22 | "vite": "^3.0.0" 23 | } 24 | }, 25 | "node_modules/@esbuild/android-arm": { 26 | "version": "0.15.18", 27 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", 28 | "integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==", 29 | "cpu": [ 30 | "arm" 31 | ], 32 | "dev": true, 33 | "optional": true, 34 | "os": [ 35 | "android" 36 | ], 37 | "engines": { 38 | "node": ">=12" 39 | } 40 | }, 41 | "node_modules/@esbuild/linux-loong64": { 42 | "version": "0.15.18", 43 | "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", 44 | "integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==", 45 | "cpu": [ 46 | "loong64" 47 | ], 48 | "dev": true, 49 | "optional": true, 50 | "os": [ 51 | "linux" 52 | ], 53 | "engines": { 54 | "node": ">=12" 55 | } 56 | }, 57 | "node_modules/@orchidjs/sifter": { 58 | "version": "1.0.3", 59 | "resolved": "https://registry.npmjs.org/@orchidjs/sifter/-/sifter-1.0.3.tgz", 60 | "integrity": "sha512-zCZbwKegHytfsPm8Amcfh7v/4vHqTAaOu6xFswBYcn8nznBOuseu6COB2ON7ez0tFV0mKL0nRNnCiZZA+lU9/g==", 61 | "dependencies": { 62 | "@orchidjs/unicode-variants": "^1.0.4" 63 | } 64 | }, 65 | "node_modules/@orchidjs/unicode-variants": { 66 | "version": "1.0.4", 67 | "resolved": "https://registry.npmjs.org/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz", 68 | "integrity": "sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ==" 69 | }, 70 | "node_modules/@popperjs/core": { 71 | "version": "2.11.8", 72 | "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", 73 | "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", 74 | "funding": { 75 | "type": "opencollective", 76 | "url": "https://opencollective.com/popperjs" 77 | } 78 | }, 79 | "node_modules/base64-arraybuffer": { 80 | "version": "1.0.2", 81 | "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", 82 | "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", 83 | "engines": { 84 | "node": ">= 0.6.0" 85 | } 86 | }, 87 | "node_modules/css-line-break": { 88 | "version": "2.1.0", 89 | "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", 90 | "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", 91 | "dependencies": { 92 | "utrie": "^1.0.2" 93 | } 94 | }, 95 | "node_modules/cytoscape": { 96 | "version": "3.27.0", 97 | "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.27.0.tgz", 98 | "integrity": "sha512-pPZJilfX9BxESwujODz5pydeGi+FBrXq1rcaB1mfhFXXFJ9GjE6CNndAk+8jPzoXGD+16LtSS4xlYEIUiW4Abg==", 99 | "dependencies": { 100 | "heap": "^0.2.6", 101 | "lodash": "^4.17.21" 102 | }, 103 | "engines": { 104 | "node": ">=0.10" 105 | } 106 | }, 107 | "node_modules/cytoscape-navigator": { 108 | "version": "2.0.2", 109 | "resolved": "https://registry.npmjs.org/cytoscape-navigator/-/cytoscape-navigator-2.0.2.tgz", 110 | "integrity": "sha512-TZFBLFWEMW858UOt4rzusOjtDj7YT5vNx2uCwpUuicUYbaWCHHcUROBZWO+hiuSPWpVhvGLFlOq3NBcAVYOAgw==", 111 | "peerDependencies": { 112 | "cytoscape": "^2.6.0 || ^3.0.0" 113 | } 114 | }, 115 | "node_modules/cytoscape-node-html-label": { 116 | "version": "1.2.2", 117 | "resolved": "https://registry.npmjs.org/cytoscape-node-html-label/-/cytoscape-node-html-label-1.2.2.tgz", 118 | "integrity": "sha512-oUVwrlsIlaJJ8QrQFSMdv3uXVXPg6tMH/Tfofr8JuZIovqI4fPqBi6sQgCMcVpS6k9Td0TTjowBsNRw32CESWg==", 119 | "peerDependencies": { 120 | "@types/cytoscape": "^3.1.0", 121 | "cytoscape": "^3.0.0" 122 | }, 123 | "peerDependenciesMeta": { 124 | "@types/cytoscape": { 125 | "optional": true 126 | } 127 | } 128 | }, 129 | "node_modules/cytoscape-popper": { 130 | "version": "2.0.0", 131 | "resolved": "https://registry.npmjs.org/cytoscape-popper/-/cytoscape-popper-2.0.0.tgz", 132 | "integrity": "sha512-b7WSOn8qXHWtdIXFNmrgc8qkaOs16tMY0EwtRXlxzvn8X+al6TAFrUwZoYATkYSlotfd/36ZMoeKMEoUck6feA==", 133 | "dependencies": { 134 | "@popperjs/core": "^2.0.0" 135 | }, 136 | "peerDependencies": { 137 | "cytoscape": "^3.2.0" 138 | } 139 | }, 140 | "node_modules/esbuild": { 141 | "version": "0.15.18", 142 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz", 143 | "integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==", 144 | "dev": true, 145 | "hasInstallScript": true, 146 | "bin": { 147 | "esbuild": "bin/esbuild" 148 | }, 149 | "engines": { 150 | "node": ">=12" 151 | }, 152 | "optionalDependencies": { 153 | "@esbuild/android-arm": "0.15.18", 154 | "@esbuild/linux-loong64": "0.15.18", 155 | "esbuild-android-64": "0.15.18", 156 | "esbuild-android-arm64": "0.15.18", 157 | "esbuild-darwin-64": "0.15.18", 158 | "esbuild-darwin-arm64": "0.15.18", 159 | "esbuild-freebsd-64": "0.15.18", 160 | "esbuild-freebsd-arm64": "0.15.18", 161 | "esbuild-linux-32": "0.15.18", 162 | "esbuild-linux-64": "0.15.18", 163 | "esbuild-linux-arm": "0.15.18", 164 | "esbuild-linux-arm64": "0.15.18", 165 | "esbuild-linux-mips64le": "0.15.18", 166 | "esbuild-linux-ppc64le": "0.15.18", 167 | "esbuild-linux-riscv64": "0.15.18", 168 | "esbuild-linux-s390x": "0.15.18", 169 | "esbuild-netbsd-64": "0.15.18", 170 | "esbuild-openbsd-64": "0.15.18", 171 | "esbuild-sunos-64": "0.15.18", 172 | "esbuild-windows-32": "0.15.18", 173 | "esbuild-windows-64": "0.15.18", 174 | "esbuild-windows-arm64": "0.15.18" 175 | } 176 | }, 177 | "node_modules/esbuild-android-64": { 178 | "version": "0.15.18", 179 | "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz", 180 | "integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==", 181 | "cpu": [ 182 | "x64" 183 | ], 184 | "dev": true, 185 | "optional": true, 186 | "os": [ 187 | "android" 188 | ], 189 | "engines": { 190 | "node": ">=12" 191 | } 192 | }, 193 | "node_modules/esbuild-android-arm64": { 194 | "version": "0.15.18", 195 | "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz", 196 | "integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==", 197 | "cpu": [ 198 | "arm64" 199 | ], 200 | "dev": true, 201 | "optional": true, 202 | "os": [ 203 | "android" 204 | ], 205 | "engines": { 206 | "node": ">=12" 207 | } 208 | }, 209 | "node_modules/esbuild-darwin-64": { 210 | "version": "0.15.18", 211 | "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz", 212 | "integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==", 213 | "cpu": [ 214 | "x64" 215 | ], 216 | "dev": true, 217 | "optional": true, 218 | "os": [ 219 | "darwin" 220 | ], 221 | "engines": { 222 | "node": ">=12" 223 | } 224 | }, 225 | "node_modules/esbuild-darwin-arm64": { 226 | "version": "0.15.18", 227 | "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz", 228 | "integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==", 229 | "cpu": [ 230 | "arm64" 231 | ], 232 | "dev": true, 233 | "optional": true, 234 | "os": [ 235 | "darwin" 236 | ], 237 | "engines": { 238 | "node": ">=12" 239 | } 240 | }, 241 | "node_modules/esbuild-freebsd-64": { 242 | "version": "0.15.18", 243 | "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz", 244 | "integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==", 245 | "cpu": [ 246 | "x64" 247 | ], 248 | "dev": true, 249 | "optional": true, 250 | "os": [ 251 | "freebsd" 252 | ], 253 | "engines": { 254 | "node": ">=12" 255 | } 256 | }, 257 | "node_modules/esbuild-freebsd-arm64": { 258 | "version": "0.15.18", 259 | "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz", 260 | "integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==", 261 | "cpu": [ 262 | "arm64" 263 | ], 264 | "dev": true, 265 | "optional": true, 266 | "os": [ 267 | "freebsd" 268 | ], 269 | "engines": { 270 | "node": ">=12" 271 | } 272 | }, 273 | "node_modules/esbuild-linux-32": { 274 | "version": "0.15.18", 275 | "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz", 276 | "integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==", 277 | "cpu": [ 278 | "ia32" 279 | ], 280 | "dev": true, 281 | "optional": true, 282 | "os": [ 283 | "linux" 284 | ], 285 | "engines": { 286 | "node": ">=12" 287 | } 288 | }, 289 | "node_modules/esbuild-linux-64": { 290 | "version": "0.15.18", 291 | "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz", 292 | "integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==", 293 | "cpu": [ 294 | "x64" 295 | ], 296 | "dev": true, 297 | "optional": true, 298 | "os": [ 299 | "linux" 300 | ], 301 | "engines": { 302 | "node": ">=12" 303 | } 304 | }, 305 | "node_modules/esbuild-linux-arm": { 306 | "version": "0.15.18", 307 | "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz", 308 | "integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==", 309 | "cpu": [ 310 | "arm" 311 | ], 312 | "dev": true, 313 | "optional": true, 314 | "os": [ 315 | "linux" 316 | ], 317 | "engines": { 318 | "node": ">=12" 319 | } 320 | }, 321 | "node_modules/esbuild-linux-arm64": { 322 | "version": "0.15.18", 323 | "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz", 324 | "integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==", 325 | "cpu": [ 326 | "arm64" 327 | ], 328 | "dev": true, 329 | "optional": true, 330 | "os": [ 331 | "linux" 332 | ], 333 | "engines": { 334 | "node": ">=12" 335 | } 336 | }, 337 | "node_modules/esbuild-linux-mips64le": { 338 | "version": "0.15.18", 339 | "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz", 340 | "integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==", 341 | "cpu": [ 342 | "mips64el" 343 | ], 344 | "dev": true, 345 | "optional": true, 346 | "os": [ 347 | "linux" 348 | ], 349 | "engines": { 350 | "node": ">=12" 351 | } 352 | }, 353 | "node_modules/esbuild-linux-ppc64le": { 354 | "version": "0.15.18", 355 | "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz", 356 | "integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==", 357 | "cpu": [ 358 | "ppc64" 359 | ], 360 | "dev": true, 361 | "optional": true, 362 | "os": [ 363 | "linux" 364 | ], 365 | "engines": { 366 | "node": ">=12" 367 | } 368 | }, 369 | "node_modules/esbuild-linux-riscv64": { 370 | "version": "0.15.18", 371 | "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz", 372 | "integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==", 373 | "cpu": [ 374 | "riscv64" 375 | ], 376 | "dev": true, 377 | "optional": true, 378 | "os": [ 379 | "linux" 380 | ], 381 | "engines": { 382 | "node": ">=12" 383 | } 384 | }, 385 | "node_modules/esbuild-linux-s390x": { 386 | "version": "0.15.18", 387 | "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz", 388 | "integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==", 389 | "cpu": [ 390 | "s390x" 391 | ], 392 | "dev": true, 393 | "optional": true, 394 | "os": [ 395 | "linux" 396 | ], 397 | "engines": { 398 | "node": ">=12" 399 | } 400 | }, 401 | "node_modules/esbuild-netbsd-64": { 402 | "version": "0.15.18", 403 | "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz", 404 | "integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==", 405 | "cpu": [ 406 | "x64" 407 | ], 408 | "dev": true, 409 | "optional": true, 410 | "os": [ 411 | "netbsd" 412 | ], 413 | "engines": { 414 | "node": ">=12" 415 | } 416 | }, 417 | "node_modules/esbuild-openbsd-64": { 418 | "version": "0.15.18", 419 | "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz", 420 | "integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==", 421 | "cpu": [ 422 | "x64" 423 | ], 424 | "dev": true, 425 | "optional": true, 426 | "os": [ 427 | "openbsd" 428 | ], 429 | "engines": { 430 | "node": ">=12" 431 | } 432 | }, 433 | "node_modules/esbuild-sunos-64": { 434 | "version": "0.15.18", 435 | "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz", 436 | "integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==", 437 | "cpu": [ 438 | "x64" 439 | ], 440 | "dev": true, 441 | "optional": true, 442 | "os": [ 443 | "sunos" 444 | ], 445 | "engines": { 446 | "node": ">=12" 447 | } 448 | }, 449 | "node_modules/esbuild-windows-32": { 450 | "version": "0.15.18", 451 | "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz", 452 | "integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==", 453 | "cpu": [ 454 | "ia32" 455 | ], 456 | "dev": true, 457 | "optional": true, 458 | "os": [ 459 | "win32" 460 | ], 461 | "engines": { 462 | "node": ">=12" 463 | } 464 | }, 465 | "node_modules/esbuild-windows-64": { 466 | "version": "0.15.18", 467 | "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz", 468 | "integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==", 469 | "cpu": [ 470 | "x64" 471 | ], 472 | "dev": true, 473 | "optional": true, 474 | "os": [ 475 | "win32" 476 | ], 477 | "engines": { 478 | "node": ">=12" 479 | } 480 | }, 481 | "node_modules/esbuild-windows-arm64": { 482 | "version": "0.15.18", 483 | "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz", 484 | "integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==", 485 | "cpu": [ 486 | "arm64" 487 | ], 488 | "dev": true, 489 | "optional": true, 490 | "os": [ 491 | "win32" 492 | ], 493 | "engines": { 494 | "node": ">=12" 495 | } 496 | }, 497 | "node_modules/fsevents": { 498 | "version": "2.3.3", 499 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 500 | "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 501 | "dev": true, 502 | "hasInstallScript": true, 503 | "optional": true, 504 | "os": [ 505 | "darwin" 506 | ], 507 | "engines": { 508 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 509 | } 510 | }, 511 | "node_modules/function-bind": { 512 | "version": "1.1.2", 513 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", 514 | "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", 515 | "dev": true, 516 | "funding": { 517 | "url": "https://github.com/sponsors/ljharb" 518 | } 519 | }, 520 | "node_modules/hasown": { 521 | "version": "2.0.0", 522 | "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", 523 | "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", 524 | "dev": true, 525 | "dependencies": { 526 | "function-bind": "^1.1.2" 527 | }, 528 | "engines": { 529 | "node": ">= 0.4" 530 | } 531 | }, 532 | "node_modules/heap": { 533 | "version": "0.2.7", 534 | "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", 535 | "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==" 536 | }, 537 | "node_modules/html2canvas": { 538 | "version": "1.4.1", 539 | "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", 540 | "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", 541 | "dependencies": { 542 | "css-line-break": "^2.1.0", 543 | "text-segmentation": "^1.0.3" 544 | }, 545 | "engines": { 546 | "node": ">=8.0.0" 547 | } 548 | }, 549 | "node_modules/is-core-module": { 550 | "version": "2.13.1", 551 | "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", 552 | "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", 553 | "dev": true, 554 | "dependencies": { 555 | "hasown": "^2.0.0" 556 | }, 557 | "funding": { 558 | "url": "https://github.com/sponsors/ljharb" 559 | } 560 | }, 561 | "node_modules/lodash": { 562 | "version": "4.17.21", 563 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", 564 | "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 565 | }, 566 | "node_modules/microplugin": { 567 | "version": "0.0.3", 568 | "resolved": "https://registry.npmjs.org/microplugin/-/microplugin-0.0.3.tgz", 569 | "integrity": "sha512-3wKXex4/iyALV0GX2juow66J9dabkEMgHeZAihdLTaRTzm0N+RubXioNPpfIQDPuBRxr3JbjNt7B0Lr/3yE9yQ==", 570 | "engines": { 571 | "node": "*" 572 | } 573 | }, 574 | "node_modules/nanoid": { 575 | "version": "3.3.7", 576 | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", 577 | "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", 578 | "dev": true, 579 | "funding": [ 580 | { 581 | "type": "github", 582 | "url": "https://github.com/sponsors/ai" 583 | } 584 | ], 585 | "bin": { 586 | "nanoid": "bin/nanoid.cjs" 587 | }, 588 | "engines": { 589 | "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 590 | } 591 | }, 592 | "node_modules/path-parse": { 593 | "version": "1.0.7", 594 | "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", 595 | "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", 596 | "dev": true 597 | }, 598 | "node_modules/picocolors": { 599 | "version": "1.0.0", 600 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", 601 | "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", 602 | "dev": true 603 | }, 604 | "node_modules/postcss": { 605 | "version": "8.4.31", 606 | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", 607 | "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", 608 | "dev": true, 609 | "funding": [ 610 | { 611 | "type": "opencollective", 612 | "url": "https://opencollective.com/postcss/" 613 | }, 614 | { 615 | "type": "tidelift", 616 | "url": "https://tidelift.com/funding/github/npm/postcss" 617 | }, 618 | { 619 | "type": "github", 620 | "url": "https://github.com/sponsors/ai" 621 | } 622 | ], 623 | "dependencies": { 624 | "nanoid": "^3.3.6", 625 | "picocolors": "^1.0.0", 626 | "source-map-js": "^1.0.2" 627 | }, 628 | "engines": { 629 | "node": "^10 || ^12 || >=14" 630 | } 631 | }, 632 | "node_modules/resolve": { 633 | "version": "1.22.8", 634 | "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", 635 | "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", 636 | "dev": true, 637 | "dependencies": { 638 | "is-core-module": "^2.13.0", 639 | "path-parse": "^1.0.7", 640 | "supports-preserve-symlinks-flag": "^1.0.0" 641 | }, 642 | "bin": { 643 | "resolve": "bin/resolve" 644 | }, 645 | "funding": { 646 | "url": "https://github.com/sponsors/ljharb" 647 | } 648 | }, 649 | "node_modules/rollup": { 650 | "version": "2.79.1", 651 | "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", 652 | "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", 653 | "dev": true, 654 | "bin": { 655 | "rollup": "dist/bin/rollup" 656 | }, 657 | "engines": { 658 | "node": ">=10.0.0" 659 | }, 660 | "optionalDependencies": { 661 | "fsevents": "~2.3.2" 662 | } 663 | }, 664 | "node_modules/sifter": { 665 | "version": "0.0.5", 666 | "resolved": "https://registry.npmjs.org/sifter/-/sifter-0.0.5.tgz", 667 | "integrity": "sha512-f3JvPPz2fCkeltoDX/Qo3COUWQQuQ/r7Amkg6QhufjpIbsqYedqvQxkdR2LfVlv6pCvdSeYE3wescken4aPg6g==", 668 | "engines": { 669 | "node": "*" 670 | } 671 | }, 672 | "node_modules/source-map-js": { 673 | "version": "1.0.2", 674 | "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", 675 | "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", 676 | "dev": true, 677 | "engines": { 678 | "node": ">=0.10.0" 679 | } 680 | }, 681 | "node_modules/supports-preserve-symlinks-flag": { 682 | "version": "1.0.0", 683 | "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", 684 | "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", 685 | "dev": true, 686 | "engines": { 687 | "node": ">= 0.4" 688 | }, 689 | "funding": { 690 | "url": "https://github.com/sponsors/ljharb" 691 | } 692 | }, 693 | "node_modules/text-segmentation": { 694 | "version": "1.0.3", 695 | "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", 696 | "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", 697 | "dependencies": { 698 | "utrie": "^1.0.2" 699 | } 700 | }, 701 | "node_modules/tippy.js": { 702 | "version": "6.3.7", 703 | "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", 704 | "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", 705 | "dependencies": { 706 | "@popperjs/core": "^2.9.0" 707 | } 708 | }, 709 | "node_modules/tom-select": { 710 | "version": "2.3.1", 711 | "resolved": "https://registry.npmjs.org/tom-select/-/tom-select-2.3.1.tgz", 712 | "integrity": "sha512-QS4vnOcB6StNGqX4sGboGXL2fkhBF2gIBB+8Hwv30FZXYPn0CyYO8kkdATRvwfCTThxiR4WcXwKJZ3cOmtI9eg==", 713 | "dependencies": { 714 | "@orchidjs/sifter": "^1.0.3", 715 | "@orchidjs/unicode-variants": "^1.0.4" 716 | }, 717 | "engines": { 718 | "node": "*" 719 | }, 720 | "funding": { 721 | "type": "opencollective", 722 | "url": "https://opencollective.com/tom-select" 723 | } 724 | }, 725 | "node_modules/utrie": { 726 | "version": "1.0.2", 727 | "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", 728 | "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", 729 | "dependencies": { 730 | "base64-arraybuffer": "^1.0.2" 731 | } 732 | }, 733 | "node_modules/vite": { 734 | "version": "3.2.7", 735 | "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz", 736 | "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==", 737 | "dev": true, 738 | "dependencies": { 739 | "esbuild": "^0.15.9", 740 | "postcss": "^8.4.18", 741 | "resolve": "^1.22.1", 742 | "rollup": "^2.79.1" 743 | }, 744 | "bin": { 745 | "vite": "bin/vite.js" 746 | }, 747 | "engines": { 748 | "node": "^14.18.0 || >=16.0.0" 749 | }, 750 | "optionalDependencies": { 751 | "fsevents": "~2.3.2" 752 | }, 753 | "peerDependencies": { 754 | "@types/node": ">= 14", 755 | "less": "*", 756 | "sass": "*", 757 | "stylus": "*", 758 | "sugarss": "*", 759 | "terser": "^5.4.0" 760 | }, 761 | "peerDependenciesMeta": { 762 | "@types/node": { 763 | "optional": true 764 | }, 765 | "less": { 766 | "optional": true 767 | }, 768 | "sass": { 769 | "optional": true 770 | }, 771 | "stylus": { 772 | "optional": true 773 | }, 774 | "sugarss": { 775 | "optional": true 776 | }, 777 | "terser": { 778 | "optional": true 779 | } 780 | } 781 | } 782 | } 783 | } 784 | -------------------------------------------------------------------------------- /frontend/main.js: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import './sprite.svg' 3 | import "cytoscape-navigator/cytoscape.js-navigator.css"; 4 | 5 | import tippy from 'tippy.js'; 6 | import 'tippy.js/dist/tippy.css'; 7 | 8 | import cytoscape from 'cytoscape'; 9 | import popper from 'cytoscape-popper'; 10 | import navigator from 'cytoscape-navigator'; 11 | import nodeHtmlLabel from 'cytoscape-node-html-label'; 12 | import html2canvas from 'html2canvas'; 13 | import { changeFilters, selectedObject, formatKey } from './selector.js'; 14 | 15 | var path, cy, authToken, currUser 16 | var locked = false; 17 | var symbols = [] 18 | var tippyDict = {} 19 | var savedData = {} 20 | 21 | var modalOpen = false; 22 | 23 | document.addEventListener('DOMContentLoaded', () => { 24 | cytoscape.use(popper); 25 | navigator(cytoscape); 26 | nodeHtmlLabel(cytoscape); 27 | // Get the current user from the API 28 | fetch(`/api/users/users/${currentUserId}/`) 29 | .then(response => response.json()) 30 | .then(data => { 31 | currUser = data 32 | }).then(() => { 33 | // Try and fetch a write enabled token 34 | fetch('/api/users/tokens/') 35 | .then(response => response.json()) 36 | .then(data => { 37 | var tokens = data.results 38 | // Iterate through the tokens 39 | for (var i = 0; i < tokens.length; i++) { 40 | // Check if the token has write permissions 41 | if (tokens[i].user['id'] == currUser.id && tokens[i].write_enabled) { 42 | // If the token expiry is null 43 | if (tokens[i].expires === null) { 44 | // Set the current token to that key 45 | authToken = tokens[i].key 46 | // Break out of the loop 47 | break 48 | } else { 49 | // If the token expiry is not null 50 | // Parse the token expiry into a date object 51 | var tokenExpiry = new Date(tokens[i].expires) 52 | // If the token expiry is in the future 53 | if (tokenExpiry > new Date()) { 54 | // Set the current token to that key 55 | authToken = tokens[i].key 56 | // Break out of the loop 57 | break 58 | } 59 | } 60 | } 61 | } 62 | // If no token was found print a warning 63 | if (authToken === undefined) { 64 | // Insert a warning message into the page 65 | var warning = document.createElement('div') 66 | warning.setAttribute('class', 'alert alert-warning mt-3') 67 | warning.innerHTML = 'No write enabled token found for ' + currUser.display + ' user. Please create a token with write permissions.' 68 | document.getElementById('global-controls').appendChild(warning) 69 | // And disable the save button 70 | document.getElementById('nbp-save').disabled = true 71 | } 72 | }) 73 | }) 74 | 75 | fetch('/static/netbox_path/sprite.svg') 76 | .then(response => response.text()) 77 | .then(svgText => { 78 | const parser = new DOMParser(); 79 | const svgDoc = parser.parseFromString(svgText, 'image/svg+xml'); 80 | symbols = svgDoc.querySelectorAll('symbol'); 81 | 82 | // Initialize the object select box 83 | 84 | changeFilters(); 85 | 86 | const writeInspector = (ele) => { 87 | var insp = document.getElementById('nbp-inspector') 88 | insp.value = JSON.stringify(ele.data(), null, 2) 89 | } 90 | 91 | const saveState = () => { 92 | var graphData = cy.json(); 93 | 94 | fetch(`/api/plugins/netbox-path/paths/${netboxPathId}/`, { 95 | method: 'PATCH', 96 | body: JSON.stringify({ 97 | graph: graphData, 98 | }), 99 | headers: { 100 | 'Content-Type': 'application/json', 101 | 'Authorization': `Token ${authToken}`, 102 | }, 103 | credentials: 'omit' 104 | }) 105 | .then((response) => response.json()) 106 | .then((json) => { 107 | console.log('Saved', json) 108 | savedData = cy.json()['elements'] 109 | }); 110 | 111 | var navigatorContainer = document.getElementById('nbp-navigator'); 112 | navigatorContainer.style.display = 'none'; 113 | cy.fit(); 114 | html2canvas(document.getElementById('nbp-cy')).then(function (canvas) { 115 | var data = canvas.toDataURL(); 116 | fetch(`/api/plugins/netbox-path/paths/${netboxPathId}/image/`, { 117 | method: 'POST', 118 | body: JSON.stringify({ 119 | image: data, 120 | }), 121 | headers: { 122 | 'Content-Type': 'application/json', 123 | 'Authorization': `Token ${authToken}`, 124 | }, 125 | credentials: 'omit' 126 | }) 127 | }) 128 | navigatorContainer.style.display = 'block'; 129 | } 130 | 131 | const deleteSelectedNodes = () => { 132 | if (!modalOpen) { 133 | var removed = cy.$(':selected').remove() 134 | } 135 | } 136 | 137 | // Change what buttons are active based on the selected node 138 | const toggleButtons = selected => { 139 | var numSelected = selected.length 140 | 141 | if (locked) { 142 | document.getElementById('nbp-lock').disabled = true; 143 | document.getElementById('nbp-unlock').disabled = false; 144 | document.getElementById('nbp-add-node').disabled = true; 145 | document.getElementById('nbp-swap-node').disabled = true; 146 | document.getElementById('nbp-save').disabled = true; 147 | } else { 148 | document.getElementById('nbp-lock').disabled = false; 149 | document.getElementById('nbp-unlock').disabled = true; 150 | document.getElementById('nbp-add-node').disabled = false; 151 | document.getElementById('nbp-swap-node').disabled = false; 152 | document.getElementById('nbp-save').disabled = false; 153 | } 154 | 155 | if (numSelected > 0) { 156 | // Some stuff is selected. Activate action buttons 157 | document.getElementById('nbp-delete-selected').disabled = false 158 | document.getElementById('nbp-edit-selected').disabled = false 159 | document.getElementById('nbp-change-icon-selected').disabled = false; 160 | 161 | if (cy.$(':selected').length > 1) { 162 | document.getElementById('nbp-link-selected').disabled = false 163 | document.getElementById('nbp-edit-selected').disabled = true 164 | document.getElementById('nbp-change-icon-selected').disabled = true; 165 | } else { 166 | document.getElementById('nbp-link-selected').disabled = true 167 | } 168 | } else { 169 | // Nothing selected - disable action buttons 170 | document.getElementById('nbp-delete-selected').disabled = true 171 | document.getElementById('nbp-link-selected').disabled = true 172 | document.getElementById('nbp-edit-selected').disabled = true 173 | document.getElementById('nbp-change-icon-selected').disabled = true; 174 | } 175 | } 176 | 177 | function groupEdges() { 178 | const groups = {}; 179 | 180 | cy.edges().forEach(edge => { 181 | const color = edge.data('color'); 182 | const label = edge.data('label'); 183 | const style = edge.data('style'); 184 | 185 | let groupKey = `${label}-${color}-${style}`; 186 | if (groupKey == `-#999999-solid`) { 187 | groupKey = 'default'; 188 | } 189 | 190 | if (!groups[groupKey]) { 191 | groups[groupKey] = []; 192 | } 193 | 194 | let group = { 195 | color: color, 196 | label: label, 197 | style: style, 198 | } 199 | 200 | groups[groupKey] = group; 201 | }); 202 | 203 | return groups; 204 | } 205 | 206 | const updateAddDropdown = () => { 207 | var dropdownMenu = document.getElementById('edge-group-dropdown-menu'); 208 | dropdownMenu.innerHTML = ''; 209 | 210 | const groups = groupEdges(); 211 | 212 | Object.entries(groups).forEach(([k, v]) => { 213 | var listItem = document.createElement('li'); 214 | 215 | 216 | var dropdownItem = document.createElement('a'); 217 | dropdownItem.className = 'dropdown-item'; 218 | dropdownItem.innerText = k; 219 | 220 | dropdownItem.onclick = () => { 221 | addNode(k); 222 | } 223 | 224 | listItem.appendChild(dropdownItem); 225 | dropdownMenu.appendChild(listItem); 226 | }); 227 | } 228 | 229 | // Change button text based on the selected node 230 | const labelButtons = selected => { 231 | var numSelected = selected.length 232 | 233 | if (numSelected > 1) { 234 | document.getElementById('nbp-add-node').innerHTML = `Add and link to ${numSelected} nodes` 235 | updateAddDropdown(); 236 | document.getElementById('nbp-add-node-dropdown').disabled = false; 237 | document.getElementById('nbp-delete-selected').innerHTML = ` Delete ${numSelected} nodes` 238 | document.getElementById('nbp-edit-selected').innerHTML = ` Edit` 239 | document.getElementById('nbp-swap-node').disabled = true; 240 | 241 | if (cy.$('node:selected').length > 1) { 242 | // The link button is only concerned with nodes as opposed to edges 243 | document.getElementById('nbp-link-selected').innerHTML = `Link ${numSelected} nodes` 244 | } 245 | 246 | } else if (numSelected === 1 && selected[0].group() == 'nodes') { 247 | document.getElementById('nbp-edit-selected').innerHTML = ` Edit Node` 248 | document.getElementById('nbp-add-node').innerHTML = `Add and link to ${selected[0].data().object.name}` 249 | document.getElementById('nbp-swap-node').innerHTML = `Swap ${selected[0].data().object.name}`; 250 | document.getElementById('nbp-swap-node').disabled = false; 251 | updateAddDropdown(); 252 | document.getElementById('nbp-add-node-dropdown').disabled = false; 253 | document.getElementById('nbp-delete-selected').innerHTML = ` Delete` 254 | document.getElementById('nbp-link-selected').innerHTML = 'Link' 255 | } else if (numSelected === 1 && selected[0].group() == 'edges') { 256 | document.getElementById('nbp-edit-selected').innerHTML = ` Edit Edge` 257 | } else { 258 | // Nothing selected 259 | document.getElementById('nbp-edit-selected').innerHTML = ` Edit` 260 | document.getElementById('nbp-add-node').innerHTML = 'Add' 261 | document.getElementById('nbp-add-node-dropdown').disabled = true; 262 | document.getElementById('nbp-swap-node').innerHTML = `Swap`; 263 | document.getElementById('nbp-swap-node').disabled = true; 264 | document.getElementById('nbp-delete-selected').innerHTML = ' Delete' 265 | document.getElementById('nbp-link-selected').innerHTML = 'Link' 266 | } 267 | } 268 | 269 | // Update the button text and disable/enable buttons based on the selected node 270 | const renderButtons = () => { 271 | var selected = cy.$(':selected') 272 | toggleButtons(selected) 273 | labelButtons(selected) 274 | } 275 | 276 | let makePopper = node => { 277 | let ref = node.popperRef(); 278 | let dummyDomEle = document.createElement("div"); 279 | 280 | let tip = tippy(dummyDomEle, { 281 | getReferenceClientRect: ref.getBoundingClientRect, 282 | trigger: "manual", 283 | 284 | content: () => { 285 | let content = document.createElement("div"); 286 | let popoverData = ``; 287 | for (var key in node.data('object')) { 288 | if (node.data('object').hasOwnProperty(key)) { 289 | popoverData += `${key}: ${node.data('object')[key]}
` 290 | } 291 | } 292 | let url = node.data('object')['url'].replace("/api/", "/");; 293 | popoverData += `
` 294 | content.innerHTML = popoverData; 295 | return content; 296 | }, 297 | 298 | interactive: true, 299 | interactiveBorder: 100, 300 | interactiveDebounce: 1000, 301 | duration: [500, 1000], 302 | offset: [0, 0], 303 | appendTo: document.body, 304 | }); 305 | tippyDict[node.id()] = tip; 306 | 307 | cy.nodes().unbind("mouseover"); 308 | cy.nodes().bind("mouseover", event => { 309 | tippyDict[event.target.id()].show(); 310 | }); 311 | 312 | cy.nodes().unbind("mouseout"); 313 | cy.nodes().bind("mouseout", event => { 314 | tippyDict[event.target.id()].hide(); 315 | }); 316 | }; 317 | 318 | // Initialize the cytojs graph 319 | const initCytoscape = (graph) => { 320 | // Init empty graph 321 | cy = cytoscape({ 322 | container: document.getElementById('nbp-cy'), 323 | layout: { 324 | name: 'grid', 325 | cols: 3, 326 | }, 327 | style: [ 328 | { 329 | selector: 'node', 330 | style: { 331 | 'background-color': 'black', 332 | //'label': 'data(label)', 333 | 'width': '40', 334 | 'height': '40', 335 | 'text-valign': 'center', 336 | 'text-halign': 'center', 337 | 'text-wrap': 'wrap', 338 | "font-size": "10px", 339 | 'color': '#fff', 340 | } 341 | }, 342 | { 343 | selector: 'edge', 344 | style: { 345 | 'width': '4', 346 | 'line-color': 'data(color)', 347 | 'target-arrow-color': 'data(color)', 348 | 'target-arrow-shape': 'triangle', 349 | 'curve-style': 'bezier', 350 | 'line-style': 'data(style)', 351 | 'label': 'data(label)', 352 | 'font-size': '10px', 353 | 'edge-text-rotation': 'autorotate', 354 | } 355 | }, 356 | { 357 | selector: ':selected', 358 | style: { 359 | 'background-color': '#f00', 360 | 'line-color': '#f00', 361 | 'target-arrow-color': '#f00', 362 | 'source-arrow-color': '#f00', 363 | } 364 | }, 365 | { 366 | selector: 'node:selected', 367 | style: { 368 | 'background-color': '#f00', 369 | 'text-outline-color': '#f00', 370 | 'text-outline-width': 3, 371 | 'line-color': '#f00', 372 | 'target-arrow-color': '#f00', 373 | 'source-arrow-color': '#f00', 374 | } 375 | }, 376 | { 377 | selector: 'edge:selected', 378 | style: { 379 | 'line-color': '#f00', 380 | 'target-arrow-color': '#f00', 381 | 'source-arrow-color': '#f00', 382 | } 383 | }, 384 | { 385 | selector: 'node[group="nodes"]', 386 | style: { 387 | 'background-color': '#f00', 388 | 'line-color': '#f00', 389 | 'target-arrow-color': '#f00', 390 | 'source-arrow-color': '#f00', 391 | } 392 | }, 393 | { 394 | selector: 'edge[group="edges"]', 395 | style: { 396 | 'line-color': '#f00', 397 | 'target-arrow-color': '#f00', 398 | 'source-arrow-color': '#f00', 399 | } 400 | }, 401 | { 402 | selector: 'node[group="nodes"]:selected', 403 | style: { 404 | 'background-color': '#f00', 405 | 'line-color': '#f00', 406 | 'target-arrow-color': '#f00', 407 | 'source-arrow-color': '#f00', 408 | } 409 | }, 410 | { 411 | selector: 'edge[group="edges"]:selected', 412 | style: { 413 | 'line-color': '#f00', 414 | 'target-arrow-color': '#f00', 415 | 'source-arrow-color': '#f00', 416 | } 417 | }, 418 | ], 419 | }) 420 | 421 | if (graph) { 422 | // Overlay data from server 423 | cy.json(graph) 424 | savedData = cy.json()['elements'] 425 | } 426 | 427 | cy.ready(() => { 428 | // Simplify starting point by unselecting everything 429 | cy.$('').unselect() 430 | 431 | cy.autoungrabify(true); 432 | cy.autounselectify(true); 433 | locked = true; 434 | 435 | renderButtons() 436 | 437 | // writeNodeSelect() 438 | }) 439 | 440 | // Or just when nodes are selected 441 | // cy.on('select', 'node', () => {}) 442 | cy.on('select', event => { 443 | // This fires once for every selected node and edge 444 | renderButtons() 445 | }) 446 | 447 | cy.on('unselect', event => { 448 | renderButtons() 449 | }) 450 | 451 | cy.on('remove', event => { 452 | renderButtons() 453 | }) 454 | 455 | cy.on('tap', event => { 456 | var ele = event.target; 457 | console.log(ele); 458 | writeInspector(ele) 459 | }) 460 | 461 | // If user clicks the destroy button, wipe local state and re-init from server data 462 | cy.on('destroy', () => { 463 | localStorage.removeItem('netbox-path') 464 | initCytoscape(path?.graph) 465 | }) 466 | 467 | cy.nodeHtmlLabel( 468 | [ 469 | { 470 | query: "node", 471 | tpl: function (data) { 472 | return ` 473 |
474 | 475 | ${Array.from(symbols).filter(function (element) { return element.getAttribute('id') == data.icon; })[0].innerHTML} 476 | 477 | ${data.label} 478 |
479 | ` 480 | }, 481 | }, 482 | { 483 | query: "node:selected", 484 | tpl: function (data) { 485 | return ` 486 |
487 | 488 | ${Array.from(symbols).filter(function (element) { return element.getAttribute('id') == data.icon; })[0].innerHTML} 489 | 490 | ${data.label} 491 |
492 | ` 493 | }, 494 | }, 495 | ], 496 | { 497 | enablePointerEvents: true 498 | } 499 | ); 500 | 501 | cy.ready(function () { 502 | cy.nodes().forEach(node => { 503 | makePopper(cy.getElementById(node.data('id'))); 504 | }); 505 | }); 506 | 507 | var defaults = { 508 | container: '#nbp-navigator', 509 | viewLiveFramerate: 0, 510 | thumbnailEventFramerate: 30, 511 | thumbnailLiveFramerate: false, 512 | dblClickDelay: 200, 513 | removeCustomContainer: false, 514 | rerenderDelay: 100 515 | }; 516 | 517 | var nav = cy.navigator(defaults); 518 | cy.fit(); 519 | } 520 | 521 | // Fetch data from server and initialise Cytoscape 522 | // var graphP = fetch(netboxPathDataUrl).then(obj => obj.json()) 523 | var fetchPath = fetch(`/api/plugins/netbox-path/paths/${netboxPathId}/`).then(obj => obj.json()) 524 | 525 | /* 526 | * Default icons for nodes 527 | */ 528 | function chooseIcon(type) { 529 | switch (type) { 530 | case 'dcim.devices': 531 | return '10855-icon-service-module'; 532 | case 'dcim.interfaces': 533 | return '10854-icon-service-media'; 534 | case 'virtualization.interfaces': 535 | return '10245-icon-service-key-vaults'; 536 | case 'circuits.circuits': 537 | return '10079-icon-service-expressroute-circuits'; 538 | case 'ipam.vlans': 539 | return '10853-icon-service-backlog'; 540 | case 'dcim.racks': 541 | return '10852-icon-service-workflow'; 542 | case 'dcim.regions': 543 | return '10851-icon-service-workbooks'; 544 | case 'dcim.sites': 545 | return '10850-icon-service-web-test'; 546 | case 'tenancy.tenants': 547 | return '10849-icon-service-web-slots'; 548 | case 'virtualization.virtual-machines': 549 | return '10848-icon-service-website-staging'; 550 | } 551 | } 552 | 553 | function swapNode() { 554 | if (selectedObject === undefined) { 555 | return; 556 | } 557 | 558 | var selectedObjectType = document.getElementById('netbox-object-type-select') 559 | var objectUrl = selectedObjectType.options[selectedObjectType.selectedIndex].getAttribute('value') 560 | var fetchData = fetch(`/api/${objectUrl}/${selectedObject}/`).then((response) => response.json()) 561 | 562 | // Set the label of the device select to the name of the selected object type 563 | var objectType = objectUrl.split('/')[1] 564 | var objectTypeLabel = formatKey(objectType); 565 | 566 | // Drop the s from the end of the object type name 567 | //if (objectTypeLabel.endsWith('s')) { 568 | // objectTypeLabel = objectTypeLabel.slice(0, -1) 569 | //} 570 | objectTypeLabel = objectUrl.replaceAll('/', '.'); 571 | 572 | Promise.all([fetchData]).then(promises => { 573 | var deviceData = promises[0] 574 | 575 | if (cy.$('node:selected').length > 0) { 576 | let existing = cy.$('node:selected')[0] 577 | existing.data('label', deviceData.display); 578 | existing.data('icon', chooseIcon(objectTypeLabel)); 579 | existing.data('object', { 580 | id: deviceData.id, 581 | type: objectTypeLabel, 582 | url: deviceData.url, 583 | display: deviceData.display, 584 | name: deviceData.name, 585 | }); 586 | 587 | } 588 | }) 589 | } 590 | 591 | function addNode(groupName) { 592 | let group = groupEdges()[groupName]; 593 | 594 | if (group === undefined) { 595 | group = { 596 | color: '#999999', 597 | label: '', 598 | style: 'solid', 599 | } 600 | } 601 | 602 | if (selectedObject === undefined) { 603 | return; 604 | } 605 | var added = [] 606 | 607 | // Get the selected value from netbox-object-type-select 608 | var selectedObjectType = document.getElementById('netbox-object-type-select') 609 | var objectUrl = selectedObjectType.options[selectedObjectType.selectedIndex].getAttribute('value') 610 | var fetchData = fetch(`/api/${objectUrl}/${selectedObject}/`).then((response) => response.json()) 611 | 612 | // Set the label of the device select to the name of the selected object type 613 | var objectType = objectUrl.split('/')[1] 614 | var objectTypeLabel = formatKey(objectType); 615 | 616 | // Drop the s from the end of the object type name 617 | //if (objectTypeLabel.endsWith('s')) { 618 | // objectTypeLabel = objectTypeLabel.slice(0, -1) 619 | //} 620 | objectTypeLabel = objectUrl.replaceAll('/', '.'); 621 | 622 | Promise.all([fetchData]).then(promises => { 623 | var deviceData = promises[0] 624 | 625 | // Generate a random id for the new node 626 | var id = Math.random().toString(36).substring(7) 627 | 628 | // Create the new node 629 | var newNode = { 630 | data: { 631 | id: id, 632 | label: deviceData.display, 633 | icon: chooseIcon(objectTypeLabel), 634 | object: { 635 | id: deviceData.id, 636 | type: objectTypeLabel, 637 | url: deviceData.url, 638 | display: deviceData.display, 639 | name: deviceData.name, 640 | }, 641 | }, 642 | } 643 | 644 | if (cy.$('node:selected').length > 0) { 645 | let existing = cy.$('node:selected')[0] 646 | // Position the new node near one of the selected nodes 647 | // TODO can we do overlap avoidance? 648 | newNode.position = { 649 | x: existing.position('x') + 200, 650 | y: existing.position('y') 651 | } 652 | } 653 | 654 | // Add the new node 655 | added = added.concat( 656 | cy.add(newNode) 657 | ) 658 | 659 | makePopper(cy.getElementById(id)); 660 | 661 | // Add an edge between all selected nodes and the new node 662 | cy.$('node:selected').forEach(s => { 663 | var newEdgeId = `edge-${s.id()}-${id}` 664 | if (cy.$id(newEdgeId).length === 0) { 665 | console.log("Adding new edge " + newEdgeId) 666 | let edge = { data: { id: newEdgeId, source: s.id(), target: id, label: group.label, style: group.style, color: group.color } } 667 | added = added.concat(cy.add(edge)) 668 | } 669 | }) 670 | 671 | if (added.length > 0) { 672 | 673 | // If we added anything, update stuff 674 | // writeNodeSelect() 675 | cy.fit() 676 | } 677 | }) 678 | } 679 | 680 | Promise.all([fetchPath]).then(promises => { 681 | path = promises[0] 682 | initCytoscape(path.graph) 683 | 684 | document.querySelector('#nbp-add-node').addEventListener('click', () => { 685 | addNode('default'); 686 | }) 687 | 688 | document.querySelector('#nbp-swap-node').addEventListener('click', () => { 689 | swapNode(); 690 | }) 691 | 692 | // Add listener for the save button 693 | document.querySelector('#nbp-save').addEventListener('click', () => { 694 | saveState() 695 | }) 696 | 697 | // document.querySelector('#nbp-revert').addEventListener('click', () => { 698 | // cy.destroy() 699 | // }) 700 | 701 | // Add listener for the fit to size button 702 | document.querySelector('#nbp-fit').addEventListener('click', () => { 703 | cy.fit() 704 | }) 705 | 706 | // document.querySelector('#nbp-node-select').addEventListener('change', event => { 707 | // cy.$('').unselect() // deselect everything else 708 | // cy.$('#' + event.target.value).select() 709 | // }) 710 | 711 | // Add listener for the delete button 712 | document.querySelector('#nbp-delete-selected').addEventListener('click', () => { 713 | deleteSelectedNodes() 714 | 715 | }) 716 | 717 | // TODO: Add dropdown for link button 718 | 719 | // Add listener for the link button 720 | document.querySelector('#nbp-link-selected').addEventListener('click', () => { 721 | // This doesn't pay much attention to ordering but we can't really know 722 | // that from the user's selection. Maybe we could look at their relative 723 | // position, and assume linking from left-to-right, top-to-bottom? 724 | // 725 | // 726 | var selected = cy.$('node:selected') 727 | 728 | // For now, create a link both ways, since we don't have a way to swap 729 | // an edge direction. Operator can delete the one they don't want. 730 | selected.forEach(a => { 731 | selected.forEach(b => { 732 | if (a.id() === b.id()) return 733 | 734 | var newEdgeId = `edge-${a.id()}-${b.id()}` 735 | if (cy.$id(newEdgeId).length === 0) { 736 | console.log("Adding new edge " + newEdgeId) 737 | let edge = { data: { id: newEdgeId, source: a.id(), target: b.id(), label: '', style: 'solid', color: '#999999' } } 738 | cy.add(edge) 739 | } 740 | }) 741 | }) 742 | }) 743 | 744 | // Listen for when to delete a node from the graph 745 | document.addEventListener('keydown', event => { 746 | switch (event.code) { 747 | case 'Backspace': 748 | case 'Delete': 749 | deleteSelectedNodes() 750 | default: 751 | return 752 | } 753 | }) 754 | 755 | document.addEventListener('hidden.bs.modal', event => { 756 | modalOpen = false; 757 | }) 758 | 759 | // Check if the filter show/hide button is pressed 760 | document.getElementById('netbox-object-filter-showhide').addEventListener('click', () => { 761 | // Toggle the visibility of the filters 762 | var filter = document.getElementById('netbox-object-filters') 763 | if (filter.style.display === 'none') { 764 | filter.style.display = 'block' 765 | } else { 766 | filter.style.display = 'none' 767 | } 768 | 769 | // Change the button text 770 | var button = document.getElementById('netbox-object-filter-showhide') 771 | if (button.innerText === 'Show Filters') { 772 | button.innerText = 'Hide Filters' 773 | } else { 774 | button.innerText = 'Show Filters' 775 | } 776 | }) 777 | 778 | document.getElementById('netbox-object-inspector-showhide').addEventListener('click', () => { 779 | // Toggle the visibility of the filters 780 | var filter = document.getElementById('netbox-object-inspector') 781 | if (filter.style.display === 'none') { 782 | filter.style.display = 'block' 783 | } else { 784 | filter.style.display = 'none' 785 | } 786 | 787 | // Change the button text 788 | var button = document.getElementById('netbox-object-inspector-showhide') 789 | if (button.innerText === 'Show Inspector') { 790 | button.innerText = 'Hide Inspector' 791 | } else { 792 | button.innerText = 'Show Inspector' 793 | } 794 | }) 795 | 796 | var selectedIcon = ''; 797 | 798 | // Check if the change icon button is pressed 799 | document.getElementById('nbp-change-icon-selected').addEventListener('click', () => { 800 | var selected = cy.elements(':selected'); 801 | if (selected.length === 0) { 802 | return 803 | } 804 | var obj = selected[0] 805 | selectedIcon = obj.data('icon'); 806 | modalOpen = true; 807 | 808 | if (obj.group() == 'nodes') { 809 | var modalNodeAttributesSection = document.getElementById('node-icons-to-show') 810 | modalNodeAttributesSection.innerHTML = '' 811 | 812 | var searchBar = document.getElementById('nbp-node-icon-search'); 813 | searchBar.value = ''; 814 | 815 | function renderIcons() { 816 | modalNodeAttributesSection.innerHTML = ''; 817 | Array.from(symbols).filter(function (element) { return element.getAttribute('id').includes(searchBar.value); }).forEach((symbol) => { 818 | var svgId = symbol.getAttribute('id'); 819 | 820 | var svgDiv = document.createElement('div'); 821 | svgDiv.className = 'col-3 col-md-2'; 822 | svgDiv.id = `node-icon-${svgId}`; 823 | 824 | var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 825 | 826 | if (svgId === selectedIcon) { 827 | svg.classList.add('border'); 828 | svg.classList.add('border-black'); 829 | svg.classList.add('rounded'); 830 | } 831 | 832 | svg.onclick = () => { 833 | modalNodeAttributesSection.childNodes.forEach((node) => { 834 | node.childNodes.forEach((childNode) => { 835 | childNode.classList.remove('border'); 836 | childNode.classList.remove('border-black'); 837 | childNode.classList.remove('rounded'); 838 | }); 839 | }); 840 | svg.classList.add('border'); 841 | svg.classList.add('border-black'); 842 | svg.classList.add('rounded'); 843 | selectedIcon = svgId; 844 | } 845 | 846 | svg.setAttribute('width', '30'); 847 | svg.setAttribute('height', '30'); 848 | svg.setAttribute('viewBox', '0 0 18 18'); 849 | svg.innerHTML = symbol.innerHTML; 850 | 851 | svgDiv.appendChild(svg); 852 | modalNodeAttributesSection.appendChild(svgDiv); 853 | }); 854 | } 855 | 856 | searchBar.oninput = () => { 857 | renderIcons(); 858 | } 859 | 860 | renderIcons(); 861 | } 862 | }) 863 | 864 | document.getElementById('nbp-node-change-icon-save').addEventListener('click', () => { 865 | var selected = cy.elements(':selected'); 866 | if (selected.length === 0) { 867 | return 868 | } 869 | 870 | var obj = selected[0] 871 | obj.data('icon', selectedIcon); 872 | }) 873 | 874 | // Check if the edit node button is pressed 875 | document.getElementById('nbp-edit-selected').addEventListener('click', () => { 876 | // Get the selected node 877 | var selected = cy.elements(':selected'); 878 | if (selected.length === 0) { 879 | return 880 | } 881 | modalOpen = true; 882 | var obj = selected[0] 883 | if (obj.group() == 'nodes') { 884 | var objectData = obj.data('object') 885 | 886 | var modalNodeAttributesSection = document.getElementById('node-attributes-to-show') 887 | modalNodeAttributesSection.innerHTML = '' 888 | 889 | var labelDiv = document.createElement('div'); 890 | labelDiv.id = `node-label-input-div`; 891 | labelDiv.className = 'form-group col-md-12'; 892 | 893 | var nodeLabel = document.createElement('label'); 894 | nodeLabel.innerText = 'Label'; 895 | nodeLabel.htmlFor = `node-label-input`; 896 | labelDiv.appendChild(nodeLabel); 897 | 898 | var labelSelect = document.createElement('select'); 899 | labelSelect.id = `node-label-input`; 900 | labelSelect.className = 'form-select'; 901 | 902 | for (var key in objectData) { 903 | var option = document.createElement("option"); 904 | if (obj.data('label') === objectData[key]) { 905 | option.selected = true; 906 | } 907 | option.value = objectData[key]; 908 | option.text = key; 909 | labelSelect.appendChild(option); 910 | } 911 | 912 | labelDiv.appendChild(labelSelect); 913 | 914 | var descriptionDiv = document.createElement('div'); 915 | descriptionDiv.id = `node-description-input-div`; 916 | descriptionDiv.className = 'form-group col-md-12'; 917 | 918 | var descriptionLabel = document.createElement('label'); 919 | descriptionLabel.innerText = 'Description'; 920 | descriptionLabel.htmlFor = `node-description-input`; 921 | descriptionDiv.appendChild(descriptionLabel); 922 | 923 | var descriptionInput = document.createElement('input'); 924 | descriptionInput.type = 'text'; 925 | descriptionInput.className = 'form-control'; 926 | descriptionInput.id = `node-description-input`; 927 | if (obj.data('description') !== undefined) { 928 | descriptionInput.value = obj.data('description'); 929 | } 930 | 931 | descriptionDiv.appendChild(descriptionInput); 932 | 933 | modalNodeAttributesSection.appendChild(labelDiv); 934 | modalNodeAttributesSection.appendChild(descriptionDiv); 935 | } else if (obj.group() == 'edges') { 936 | var modalNodeAttributesSection = document.getElementById('node-attributes-to-show') 937 | modalNodeAttributesSection.innerHTML = '' 938 | 939 | const groups = groupEdges(); 940 | 941 | var groupDiv = document.createElement('div'); 942 | groupDiv.id = `edge-group-input-div`; 943 | groupDiv.className = 'form-group col-md-12'; 944 | 945 | var groupLabel = document.createElement('label'); 946 | groupLabel.innerText = 'Group'; 947 | groupLabel.htmlFor = `edge-group-input`; 948 | groupDiv.appendChild(groupLabel); 949 | 950 | var groupSelect = document.createElement('select'); 951 | groupSelect.id = `edge-group-input`; 952 | groupSelect.className = 'form-select'; 953 | 954 | var newOption = document.createElement("option"); 955 | newOption.value = 'new'; 956 | newOption.text = 'New'; 957 | groupSelect.appendChild(newOption); 958 | 959 | Object.entries(groups).forEach(([k, v]) => { 960 | var option = document.createElement("option"); 961 | if (obj.data('label') === v.label && obj.data('color') === v.color && obj.data('style') === v.style) { 962 | option.selected = true; 963 | } 964 | option.value = k; 965 | option.text = k; 966 | groupSelect.appendChild(option); 967 | }) 968 | 969 | groupDiv.appendChild(groupSelect); 970 | 971 | var inputDiv = document.createElement('div'); 972 | inputDiv.id = `edge-label-input-div`; 973 | inputDiv.className = 'form-group col-md-12'; 974 | 975 | var label = document.createElement('label'); 976 | label.innerText = 'Label'; 977 | label.htmlFor = `edge-label-input`; 978 | inputDiv.appendChild(label); 979 | 980 | var input = document.createElement('input'); 981 | input.type = 'text'; 982 | input.className = 'form-control'; 983 | input.id = `edge-label-input`; 984 | if (obj.data('label') !== undefined) { 985 | input.value = obj.data('label'); 986 | } 987 | 988 | inputDiv.appendChild(input); 989 | 990 | let options = ['solid', 'dotted', 'dashed']; 991 | 992 | var typeDiv = document.createElement('div'); 993 | typeDiv.id = `edge-type-input-div`; 994 | typeDiv.className = 'form-group col-md-12'; 995 | 996 | var typeLabel = document.createElement('label'); 997 | typeLabel.innerText = 'Type'; 998 | typeLabel.htmlFor = `edge-type-input`; 999 | typeDiv.appendChild(typeLabel); 1000 | 1001 | var typeSelect = document.createElement('select'); 1002 | typeSelect.id = `edge-type-input`; 1003 | typeSelect.className = 'form-select'; 1004 | 1005 | for (var i = 0; i < options.length; i++) { 1006 | var option = document.createElement("option"); 1007 | if (obj.data('style') !== undefined && obj.data('style') === options[i]) { 1008 | option.selected = true; 1009 | } 1010 | option.value = options[i]; 1011 | option.text = options[i][0].toUpperCase() + options[i].slice(1); 1012 | typeSelect.appendChild(option); 1013 | } 1014 | 1015 | typeDiv.appendChild(typeSelect); 1016 | 1017 | var colorDiv = document.createElement('div'); 1018 | colorDiv.id = `edge-color-input-div`; 1019 | colorDiv.className = 'form-group col-md-12'; 1020 | 1021 | var colorLabel = document.createElement('label'); 1022 | colorLabel.innerText = 'Color'; 1023 | colorLabel.htmlFor = `edge-color-input`; 1024 | colorDiv.appendChild(colorLabel); 1025 | 1026 | var colorGroup = document.createElement('div'); 1027 | colorGroup.className = 'input-group'; 1028 | 1029 | var colorGroupPrepend = document.createElement('div'); 1030 | colorGroupPrepend.className = 'input-group-prepend'; 1031 | 1032 | var colorPicker = document.createElement('input'); 1033 | colorPicker.type = 'color'; 1034 | colorPicker.className = 'input-group-text form-control-color'; 1035 | colorPicker.value = obj.data('color'); 1036 | 1037 | var colorInput = document.createElement('input'); 1038 | colorInput.type = 'text'; 1039 | colorInput.className = 'edge-color-input form-control'; 1040 | colorInput.value = obj.data('color'); 1041 | colorInput.id = `edge-color-input`; 1042 | colorPicker.addEventListener('input', function () { 1043 | colorInput.value = colorPicker.value; 1044 | }); 1045 | 1046 | colorInput.addEventListener('input', function () { 1047 | colorPicker.value = colorInput.value; 1048 | }); 1049 | 1050 | colorGroupPrepend.appendChild(colorPicker); 1051 | 1052 | colorGroup.appendChild(colorGroupPrepend); 1053 | colorGroup.appendChild(colorInput); 1054 | 1055 | colorDiv.appendChild(colorGroup); 1056 | 1057 | if (groupSelect.value !== 'new') { 1058 | inputDiv.hidden = true; 1059 | typeDiv.hidden = true; 1060 | colorDiv.hidden = true; 1061 | } 1062 | 1063 | groupSelect.addEventListener('change', function () { 1064 | const selectedValue = document.getElementById(`edge-group-input`).value; 1065 | if (selectedValue === 'new') { 1066 | inputDiv.hidden = false; 1067 | typeDiv.hidden = false; 1068 | colorDiv.hidden = false; 1069 | } else { 1070 | inputDiv.hidden = true; 1071 | typeDiv.hidden = true; 1072 | colorDiv.hidden = true; 1073 | const selectedGroup = groups[selectedValue]; 1074 | 1075 | input.value = selectedGroup.label; 1076 | typeSelect.value = selectedGroup.style; 1077 | colorPicker.value = selectedGroup.color; 1078 | colorInput.value = selectedGroup.color; 1079 | } 1080 | }); 1081 | 1082 | modalNodeAttributesSection.appendChild(groupDiv); 1083 | modalNodeAttributesSection.appendChild(inputDiv); 1084 | modalNodeAttributesSection.appendChild(typeDiv); 1085 | modalNodeAttributesSection.appendChild(colorDiv); 1086 | } 1087 | }) 1088 | 1089 | // Check if the modal save button is pressed 1090 | document.getElementById('nbp-node-edit-save').addEventListener('click', () => { 1091 | // Get the selected node 1092 | var selected = cy.$(':selected') 1093 | if (selected.length === 0) { 1094 | return 1095 | } 1096 | var obj = selected[0] 1097 | 1098 | if (obj.group() == 'nodes') { 1099 | // Get the values submitted from the modal 1100 | var modalNodeAttributesSection = document.getElementById('node-attributes-to-show') 1101 | var descriptionInput = modalNodeAttributesSection.querySelector('.form-control'); 1102 | var labelType = document.getElementById('node-label-input'); 1103 | 1104 | obj.data('label', labelType.value); 1105 | obj.data('description', descriptionInput.value); 1106 | } else if (obj.group() == 'edges') { 1107 | var modalNodeAttributesSection = document.getElementById('node-attributes-to-show'); 1108 | 1109 | var input = modalNodeAttributesSection.querySelector('.form-control'); 1110 | var typeSelect = modalNodeAttributesSection.querySelector('#edge-type-input'); 1111 | var colorInput = modalNodeAttributesSection.querySelector('.form-control-color'); 1112 | var colorInput = modalNodeAttributesSection.querySelector('.edge-color-input'); 1113 | 1114 | obj.data('style', typeSelect.value); 1115 | obj.data('label', input.value) 1116 | obj.data('color', colorInput.value); 1117 | } 1118 | }) 1119 | 1120 | document.getElementById('nbp-lock').addEventListener('click', () => { 1121 | locked = true; 1122 | cy.autoungrabify(true); 1123 | cy.autounselectify(true); 1124 | 1125 | toggleButtons(cy.$(':selected')); 1126 | }); 1127 | 1128 | document.getElementById('nbp-unlock').addEventListener('click', () => { 1129 | locked = false; 1130 | cy.autoungrabify(false); 1131 | cy.autounselectify(false); 1132 | 1133 | toggleButtons(cy.$(':selected')); 1134 | }); 1135 | 1136 | // Get the value of the object type when the user selects it 1137 | document.getElementById('netbox-object-type-select').addEventListener('change', function () { 1138 | changeFilters() 1139 | }) 1140 | 1141 | document.getElementById('nbp-screenshot-selected').addEventListener('click', () => { 1142 | var navigatorContainer = document.getElementById('nbp-navigator'); 1143 | navigatorContainer.style.display = 'none'; 1144 | html2canvas(document.getElementById('nbp-cy')).then(function (canvas) { 1145 | var anchorTag = document.createElement("a"); 1146 | anchorTag.download = "netbox-path.jpg"; 1147 | anchorTag.href = canvas.toDataURL(); 1148 | anchorTag.target = '_blank'; 1149 | anchorTag.click(); 1150 | }) 1151 | navigatorContainer.style.display = 'block'; 1152 | }) 1153 | 1154 | window.addEventListener('beforeunload', function (event) { 1155 | if (!locked) { 1156 | if (JSON.stringify(savedData) !== JSON.stringify(cy.json()['elements'])) { 1157 | event.preventDefault(); 1158 | event.returnValue = ''; 1159 | return 'If you exit all unsaved data will be lost.'; 1160 | } 1161 | } 1162 | }); 1163 | }); 1164 | }); 1165 | }) --------------------------------------------------------------------------------