netbox-toolkit-plugin 0.1.2__py3-none-any.whl → 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netbox_toolkit_plugin/__init__.py +1 -1
- netbox_toolkit_plugin/admin.py +11 -7
- netbox_toolkit_plugin/api/mixins.py +20 -16
- netbox_toolkit_plugin/api/schemas.py +53 -74
- netbox_toolkit_plugin/api/serializers.py +10 -11
- netbox_toolkit_plugin/api/urls.py +2 -1
- netbox_toolkit_plugin/api/views/__init__.py +4 -3
- netbox_toolkit_plugin/api/views/command_logs.py +80 -73
- netbox_toolkit_plugin/api/views/commands.py +140 -134
- netbox_toolkit_plugin/connectors/__init__.py +9 -9
- netbox_toolkit_plugin/connectors/base.py +30 -31
- netbox_toolkit_plugin/connectors/factory.py +21 -25
- netbox_toolkit_plugin/connectors/netmiko_connector.py +18 -28
- netbox_toolkit_plugin/connectors/scrapli_connector.py +17 -16
- netbox_toolkit_plugin/exceptions.py +0 -7
- netbox_toolkit_plugin/filtersets.py +26 -42
- netbox_toolkit_plugin/forms.py +13 -11
- netbox_toolkit_plugin/migrations/0008_remove_parsed_data_storage.py +26 -0
- netbox_toolkit_plugin/models.py +2 -17
- netbox_toolkit_plugin/navigation.py +3 -0
- netbox_toolkit_plugin/search.py +12 -9
- netbox_toolkit_plugin/services/__init__.py +1 -1
- netbox_toolkit_plugin/services/command_service.py +6 -9
- netbox_toolkit_plugin/services/device_service.py +40 -32
- netbox_toolkit_plugin/services/rate_limiting_service.py +4 -3
- netbox_toolkit_plugin/static/netbox_toolkit_plugin/js/toolkit.js +245 -119
- netbox_toolkit_plugin/tables.py +10 -1
- netbox_toolkit_plugin/templates/netbox_toolkit_plugin/commandlog.html +16 -84
- netbox_toolkit_plugin/templates/netbox_toolkit_plugin/device_toolkit.html +37 -33
- netbox_toolkit_plugin/urls.py +10 -3
- netbox_toolkit_plugin/utils/connection.py +54 -54
- netbox_toolkit_plugin/utils/error_parser.py +128 -109
- netbox_toolkit_plugin/utils/logging.py +1 -0
- netbox_toolkit_plugin/utils/network.py +74 -47
- netbox_toolkit_plugin/views.py +51 -22
- {netbox_toolkit_plugin-0.1.2.dist-info → netbox_toolkit_plugin-0.1.3.dist-info}/METADATA +2 -2
- netbox_toolkit_plugin-0.1.3.dist-info/RECORD +61 -0
- netbox_toolkit_plugin-0.1.2.dist-info/RECORD +0 -60
- {netbox_toolkit_plugin-0.1.2.dist-info → netbox_toolkit_plugin-0.1.3.dist-info}/WHEEL +0 -0
- {netbox_toolkit_plugin-0.1.2.dist-info → netbox_toolkit_plugin-0.1.3.dist-info}/entry_points.txt +0 -0
- {netbox_toolkit_plugin-0.1.2.dist-info → netbox_toolkit_plugin-0.1.3.dist-info}/licenses/LICENSE +0 -0
- {netbox_toolkit_plugin-0.1.2.dist-info → netbox_toolkit_plugin-0.1.3.dist-info}/top_level.txt +0 -0
netbox_toolkit_plugin/forms.py
CHANGED
@@ -1,31 +1,33 @@
|
|
1
1
|
from django import forms
|
2
|
-
|
2
|
+
|
3
|
+
from dcim.models import Platform
|
3
4
|
from netbox.forms import NetBoxModelForm
|
4
5
|
from utilities.forms.fields import DynamicModelChoiceField
|
5
|
-
|
6
|
+
|
6
7
|
from .models import Command, CommandLog
|
7
8
|
|
9
|
+
|
8
10
|
class CommandForm(NetBoxModelForm):
|
9
11
|
platform = DynamicModelChoiceField(
|
10
12
|
queryset=Platform.objects.all(),
|
11
|
-
help_text="Platform this command is designed for (e.g., cisco_ios, cisco_nxos, generic)"
|
13
|
+
help_text="Platform this command is designed for (e.g., cisco_ios, cisco_nxos, generic)",
|
12
14
|
)
|
13
|
-
|
15
|
+
|
14
16
|
class Meta:
|
15
17
|
model = Command
|
16
|
-
fields = (
|
18
|
+
fields = ("name", "command", "description", "platform", "command_type", "tags")
|
19
|
+
|
17
20
|
|
18
21
|
class CommandLogForm(NetBoxModelForm):
|
19
22
|
class Meta:
|
20
23
|
model = CommandLog
|
21
|
-
fields = (
|
24
|
+
fields = ("command", "device", "output", "username")
|
25
|
+
|
22
26
|
|
23
27
|
class CommandExecutionForm(forms.Form):
|
24
28
|
username = forms.CharField(
|
25
|
-
max_length=100,
|
26
|
-
help_text="Username for device authentication"
|
29
|
+
max_length=100, help_text="Username for device authentication"
|
27
30
|
)
|
28
31
|
password = forms.CharField(
|
29
|
-
widget=forms.PasswordInput,
|
30
|
-
|
31
|
-
)
|
32
|
+
widget=forms.PasswordInput, help_text="Password for device authentication"
|
33
|
+
)
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# Generated migration to remove parsed data storage
|
2
|
+
|
3
|
+
from django.db import migrations
|
4
|
+
|
5
|
+
|
6
|
+
class Migration(migrations.Migration):
|
7
|
+
|
8
|
+
dependencies = [
|
9
|
+
('netbox_toolkit_plugin', '0007_alter_commandlog_parsing_template'),
|
10
|
+
]
|
11
|
+
|
12
|
+
operations = [
|
13
|
+
# Remove parsed data fields since we now parse fresh from raw output
|
14
|
+
migrations.RemoveField(
|
15
|
+
model_name='commandlog',
|
16
|
+
name='parsed_data',
|
17
|
+
),
|
18
|
+
migrations.RemoveField(
|
19
|
+
model_name='commandlog',
|
20
|
+
name='parsing_success',
|
21
|
+
),
|
22
|
+
migrations.RemoveField(
|
23
|
+
model_name='commandlog',
|
24
|
+
name='parsing_template',
|
25
|
+
),
|
26
|
+
]
|
netbox_toolkit_plugin/models.py
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
from django.db import models
|
2
|
-
|
2
|
+
|
3
3
|
from netbox.models import NetBoxModel
|
4
|
-
from dcim.models import Device
|
5
4
|
|
6
5
|
|
7
6
|
class Command(NetBoxModel):
|
@@ -55,27 +54,13 @@ class CommandLog(NetBoxModel):
|
|
55
54
|
username = models.CharField(max_length=100)
|
56
55
|
execution_time = models.DateTimeField(auto_now_add=True)
|
57
56
|
|
58
|
-
# Execution details
|
57
|
+
# Execution details
|
59
58
|
success = models.BooleanField(default=True)
|
60
59
|
error_message = models.TextField(blank=True)
|
61
60
|
execution_duration = models.FloatField(
|
62
61
|
blank=True, null=True, help_text="Command execution time in seconds"
|
63
62
|
)
|
64
63
|
|
65
|
-
# Parsing details for TextFSM
|
66
|
-
parsed_data = models.JSONField(
|
67
|
-
blank=True, null=True, help_text="Parsed structured data from command output"
|
68
|
-
)
|
69
|
-
parsing_success = models.BooleanField(
|
70
|
-
default=False, help_text="Whether the output was successfully parsed"
|
71
|
-
)
|
72
|
-
parsing_template = models.CharField(
|
73
|
-
max_length=255,
|
74
|
-
blank=True,
|
75
|
-
null=True,
|
76
|
-
help_text="Name of the TextFSM template used for parsing",
|
77
|
-
)
|
78
|
-
|
79
64
|
def __str__(self):
|
80
65
|
return f"{self.command} on {self.device}"
|
81
66
|
|
@@ -11,18 +11,21 @@ menu = PluginMenu(
|
|
11
11
|
PluginMenuItem(
|
12
12
|
link="plugins:netbox_toolkit_plugin:command_list",
|
13
13
|
link_text="Commands",
|
14
|
+
permissions=["netbox_toolkit_plugin.view_command"],
|
14
15
|
buttons=(
|
15
16
|
PluginMenuButton(
|
16
17
|
"plugins:netbox_toolkit_plugin:command_add",
|
17
18
|
"Add",
|
18
19
|
"mdi mdi-plus-thick",
|
19
20
|
ButtonColorChoices.GRAY,
|
21
|
+
permissions=["netbox_toolkit_plugin.add_command"],
|
20
22
|
),
|
21
23
|
),
|
22
24
|
),
|
23
25
|
PluginMenuItem(
|
24
26
|
link="plugins:netbox_toolkit_plugin:commandlog_list",
|
25
27
|
link_text="Command Logs",
|
28
|
+
permissions=["netbox_toolkit_plugin.view_commandlog"],
|
26
29
|
),
|
27
30
|
),
|
28
31
|
),
|
netbox_toolkit_plugin/search.py
CHANGED
@@ -1,21 +1,24 @@
|
|
1
1
|
from netbox.search import SearchIndex
|
2
|
+
|
2
3
|
from .models import Command, CommandLog
|
3
4
|
|
5
|
+
|
4
6
|
class CommandIndex(SearchIndex):
|
5
7
|
model = Command
|
6
8
|
fields = (
|
7
|
-
(
|
8
|
-
(
|
9
|
-
(
|
9
|
+
("name", 100),
|
10
|
+
("command", 200),
|
11
|
+
("description", 500),
|
10
12
|
)
|
11
|
-
display_attrs = (
|
13
|
+
display_attrs = ("platform", "command_type", "description")
|
14
|
+
|
12
15
|
|
13
16
|
class CommandLogIndex(SearchIndex):
|
14
17
|
model = CommandLog
|
15
18
|
fields = (
|
16
|
-
(
|
17
|
-
(
|
18
|
-
(
|
19
|
-
(
|
19
|
+
("command__name", 100),
|
20
|
+
("device__name", 150),
|
21
|
+
("username", 200),
|
22
|
+
("output", 1000),
|
20
23
|
)
|
21
|
-
display_attrs = (
|
24
|
+
display_attrs = ("command", "device", "success", "execution_time")
|
@@ -4,4 +4,4 @@ from .command_service import CommandExecutionService
|
|
4
4
|
from .device_service import DeviceService
|
5
5
|
from .rate_limiting_service import RateLimitingService
|
6
6
|
|
7
|
-
__all__ = [
|
7
|
+
__all__ = ["CommandExecutionService", "DeviceService", "RateLimitingService"]
|
@@ -1,15 +1,15 @@
|
|
1
1
|
"""Service for handling command execution on devices."""
|
2
2
|
|
3
3
|
import traceback
|
4
|
-
from typing import
|
4
|
+
from typing import Any
|
5
5
|
|
6
6
|
from dcim.models import Device
|
7
7
|
|
8
|
-
from ..models import Command, CommandLog
|
9
|
-
from ..connectors.factory import ConnectorFactory
|
10
8
|
from ..connectors.base import CommandResult
|
9
|
+
from ..connectors.factory import ConnectorFactory
|
11
10
|
from ..connectors.netmiko_connector import NetmikoConnector
|
12
|
-
from ..exceptions import DeviceConnectionError
|
11
|
+
from ..exceptions import DeviceConnectionError
|
12
|
+
from ..models import Command, CommandLog
|
13
13
|
from ..settings import ToolkitSettings
|
14
14
|
from ..utils.logging import get_toolkit_logger
|
15
15
|
|
@@ -24,7 +24,7 @@ class CommandExecutionService:
|
|
24
24
|
|
25
25
|
def execute_command_with_retry(
|
26
26
|
self,
|
27
|
-
command: "
|
27
|
+
command: "Command",
|
28
28
|
device: Any,
|
29
29
|
username: str,
|
30
30
|
password: str,
|
@@ -231,7 +231,7 @@ class CommandExecutionService:
|
|
231
231
|
success = False
|
232
232
|
error_message = result.error_message or ""
|
233
233
|
|
234
|
-
# Create log entry with execution details
|
234
|
+
# Create log entry with execution details (raw output only)
|
235
235
|
command_log = CommandLog.objects.create(
|
236
236
|
command=command,
|
237
237
|
device=device,
|
@@ -240,9 +240,6 @@ class CommandExecutionService:
|
|
240
240
|
success=success,
|
241
241
|
error_message=error_message,
|
242
242
|
execution_duration=result.execution_time,
|
243
|
-
parsed_data=result.parsed_output,
|
244
|
-
parsing_success=result.parsing_success,
|
245
|
-
parsing_template=result.parsing_method,
|
246
243
|
)
|
247
244
|
|
248
245
|
if result.has_syntax_error:
|
@@ -1,5 +1,4 @@
|
|
1
1
|
"""Service for device-related operations."""
|
2
|
-
from typing import List, Optional
|
3
2
|
|
4
3
|
from dcim.models import Device
|
5
4
|
|
@@ -8,80 +7,89 @@ from ..models import Command
|
|
8
7
|
|
9
8
|
class DeviceService:
|
10
9
|
"""Service for device-related operations."""
|
11
|
-
|
10
|
+
|
12
11
|
@staticmethod
|
13
|
-
def get_available_commands(device: Device) ->
|
12
|
+
def get_available_commands(device: Device) -> list[Command]:
|
14
13
|
"""
|
15
14
|
Get all commands available for a device based on its platform.
|
16
|
-
|
15
|
+
|
17
16
|
Args:
|
18
17
|
device: The device to get commands for
|
19
|
-
|
18
|
+
|
20
19
|
Returns:
|
21
20
|
List of available commands
|
22
21
|
"""
|
23
22
|
if not device.platform:
|
24
23
|
return []
|
25
|
-
|
24
|
+
|
26
25
|
commands = Command.objects.filter(platform=device.platform)
|
27
|
-
|
26
|
+
|
28
27
|
return list(commands)
|
29
|
-
|
28
|
+
|
30
29
|
@staticmethod
|
31
30
|
def get_device_connection_info(device: Device) -> dict:
|
32
31
|
"""
|
33
32
|
Get connection information for a device.
|
34
|
-
|
33
|
+
|
35
34
|
Args:
|
36
35
|
device: The device to get connection info for
|
37
|
-
|
36
|
+
|
38
37
|
Returns:
|
39
38
|
Dictionary with connection information
|
40
39
|
"""
|
41
|
-
hostname =
|
40
|
+
hostname = (
|
41
|
+
str(device.primary_ip.address.ip) if device.primary_ip else device.name
|
42
|
+
)
|
42
43
|
platform = str(device.platform).lower() if device.platform else None
|
43
|
-
|
44
|
+
|
44
45
|
info = {
|
45
|
-
|
46
|
-
|
47
|
-
|
46
|
+
"hostname": hostname,
|
47
|
+
"platform": platform,
|
48
|
+
"has_primary_ip": device.primary_ip is not None,
|
48
49
|
}
|
49
|
-
|
50
|
+
|
50
51
|
return info
|
51
|
-
|
52
|
+
|
52
53
|
@staticmethod
|
53
|
-
def validate_device_for_commands(
|
54
|
+
def validate_device_for_commands(
|
55
|
+
device: Device,
|
56
|
+
) -> tuple[bool, str | None, dict]:
|
54
57
|
"""
|
55
58
|
Validate if a device is ready for command execution.
|
56
|
-
|
59
|
+
|
57
60
|
Args:
|
58
61
|
device: The device to validate
|
59
|
-
|
62
|
+
|
60
63
|
Returns:
|
61
64
|
Tuple of (is_valid, error_message, validation_checks)
|
62
65
|
validation_checks is a dict with check names as keys and booleans as values
|
63
66
|
"""
|
64
67
|
# Initialize validation checks
|
65
68
|
validation_checks = {
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
69
|
+
"has_platform": device.platform is not None,
|
70
|
+
"has_primary_ip": device.primary_ip is not None,
|
71
|
+
"has_hostname": bool(device.name), # Check if device has a name
|
72
|
+
"platform_supported": False, # Will be set below if platform exists
|
70
73
|
}
|
71
|
-
|
74
|
+
|
72
75
|
# Additional logic for more complex checks
|
73
|
-
if validation_checks[
|
74
|
-
validation_checks[
|
75
|
-
|
76
|
+
if validation_checks["has_platform"]:
|
77
|
+
validation_checks["platform_supported"] = (
|
78
|
+
True # If platform exists, we consider it supported
|
79
|
+
)
|
80
|
+
|
76
81
|
# Determine overall validity and error message
|
77
82
|
is_valid = True
|
78
83
|
error_message = None
|
79
|
-
|
80
|
-
if not validation_checks[
|
84
|
+
|
85
|
+
if not validation_checks["has_platform"]:
|
81
86
|
is_valid = False
|
82
87
|
error_message = "Device has no platform assigned"
|
83
|
-
elif
|
88
|
+
elif (
|
89
|
+
not validation_checks["has_primary_ip"]
|
90
|
+
and not validation_checks["has_hostname"]
|
91
|
+
):
|
84
92
|
is_valid = False
|
85
93
|
error_message = "Device has no primary IP address or hostname"
|
86
|
-
|
94
|
+
|
87
95
|
return is_valid, error_message, validation_checks
|
@@ -1,7 +1,8 @@
|
|
1
|
-
from datetime import
|
2
|
-
|
1
|
+
from datetime import timedelta
|
2
|
+
|
3
3
|
from django.conf import settings
|
4
|
-
from django.
|
4
|
+
from django.utils import timezone
|
5
|
+
|
5
6
|
from ..models import CommandLog
|
6
7
|
|
7
8
|
|