netbox-toolkit-plugin 0.1.0__py3-none-any.whl → 0.1.2__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.
Files changed (69) hide show
  1. netbox_toolkit_plugin/__init__.py +32 -0
  2. {netbox_toolkit → netbox_toolkit_plugin}/api/serializers.py +71 -35
  3. {netbox_toolkit → netbox_toolkit_plugin}/api/urls.py +3 -3
  4. {netbox_toolkit → netbox_toolkit_plugin}/connectors/factory.py +170 -111
  5. {netbox_toolkit → netbox_toolkit_plugin}/connectors/netmiko_connector.py +242 -179
  6. {netbox_toolkit → netbox_toolkit_plugin}/connectors/scrapli_connector.py +256 -172
  7. netbox_toolkit_plugin/migrations/0001_initial.py +108 -0
  8. netbox_toolkit_plugin/migrations/0002_alter_command_options_alter_command_unique_together_and_more.py +70 -0
  9. {netbox_toolkit → netbox_toolkit_plugin}/migrations/0003_permission_system_update.py +26 -12
  10. {netbox_toolkit → netbox_toolkit_plugin}/migrations/0004_remove_django_permissions.py +27 -29
  11. {netbox_toolkit → netbox_toolkit_plugin}/migrations/0005_alter_command_options_and_more.py +7 -8
  12. {netbox_toolkit → netbox_toolkit_plugin}/migrations/0006_commandlog_parsed_data_commandlog_parsing_success_and_more.py +7 -8
  13. {netbox_toolkit → netbox_toolkit_plugin}/migrations/0007_alter_commandlog_parsing_template.py +6 -4
  14. {netbox_toolkit → netbox_toolkit_plugin}/models.py +31 -32
  15. {netbox_toolkit → netbox_toolkit_plugin}/navigation.py +6 -6
  16. {netbox_toolkit → netbox_toolkit_plugin}/services/command_service.py +188 -128
  17. {netbox_toolkit → netbox_toolkit_plugin}/services/rate_limiting_service.py +104 -97
  18. netbox_toolkit_plugin/settings.py +176 -0
  19. netbox_toolkit_plugin/tables.py +51 -0
  20. netbox_toolkit_plugin/templates/netbox_toolkit/command.html +108 -0
  21. netbox_toolkit_plugin/templates/netbox_toolkit/command_list.html +12 -0
  22. netbox_toolkit_plugin/templates/netbox_toolkit/commandlog.html +170 -0
  23. netbox_toolkit_plugin/templates/netbox_toolkit/device_toolkit.html +557 -0
  24. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/command.html +5 -5
  25. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/command_list.html +2 -2
  26. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/commandlog.html +2 -2
  27. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/device_toolkit.html +6 -6
  28. netbox_toolkit_plugin/urls.py +38 -0
  29. {netbox_toolkit → netbox_toolkit_plugin}/utils/logging.py +20 -19
  30. {netbox_toolkit → netbox_toolkit_plugin}/views.py +251 -169
  31. {netbox_toolkit_plugin-0.1.0.dist-info → netbox_toolkit_plugin-0.1.2.dist-info}/METADATA +2 -2
  32. netbox_toolkit_plugin-0.1.2.dist-info/RECORD +60 -0
  33. netbox_toolkit_plugin-0.1.2.dist-info/entry_points.txt +2 -0
  34. netbox_toolkit_plugin-0.1.2.dist-info/top_level.txt +1 -0
  35. netbox_toolkit/__init__.py +0 -30
  36. netbox_toolkit/config.py +0 -159
  37. netbox_toolkit/migrations/0001_initial.py +0 -54
  38. netbox_toolkit/migrations/0002_alter_command_options_alter_command_unique_together_and_more.py +0 -66
  39. netbox_toolkit/tables.py +0 -37
  40. netbox_toolkit/urls.py +0 -22
  41. netbox_toolkit_plugin-0.1.0.dist-info/RECORD +0 -56
  42. netbox_toolkit_plugin-0.1.0.dist-info/entry_points.txt +0 -2
  43. netbox_toolkit_plugin-0.1.0.dist-info/top_level.txt +0 -1
  44. {netbox_toolkit → netbox_toolkit_plugin}/admin.py +0 -0
  45. {netbox_toolkit → netbox_toolkit_plugin}/api/__init__.py +0 -0
  46. {netbox_toolkit → netbox_toolkit_plugin}/api/mixins.py +0 -0
  47. {netbox_toolkit → netbox_toolkit_plugin}/api/schemas.py +0 -0
  48. {netbox_toolkit → netbox_toolkit_plugin}/api/views/__init__.py +0 -0
  49. {netbox_toolkit → netbox_toolkit_plugin}/api/views/command_logs.py +0 -0
  50. {netbox_toolkit → netbox_toolkit_plugin}/api/views/commands.py +0 -0
  51. {netbox_toolkit → netbox_toolkit_plugin}/connectors/__init__.py +0 -0
  52. {netbox_toolkit → netbox_toolkit_plugin}/connectors/base.py +0 -0
  53. {netbox_toolkit → netbox_toolkit_plugin}/exceptions.py +0 -0
  54. {netbox_toolkit → netbox_toolkit_plugin}/filtersets.py +0 -0
  55. {netbox_toolkit → netbox_toolkit_plugin}/forms.py +0 -0
  56. {netbox_toolkit → netbox_toolkit_plugin}/migrations/__init__.py +0 -0
  57. {netbox_toolkit → netbox_toolkit_plugin}/search.py +0 -0
  58. {netbox_toolkit → netbox_toolkit_plugin}/services/__init__.py +0 -0
  59. {netbox_toolkit → netbox_toolkit_plugin}/services/device_service.py +0 -0
  60. {netbox_toolkit/static/netbox_toolkit → netbox_toolkit_plugin/static/netbox_toolkit_plugin}/css/toolkit.css +0 -0
  61. {netbox_toolkit/static/netbox_toolkit → netbox_toolkit_plugin/static/netbox_toolkit_plugin}/js/toolkit.js +0 -0
  62. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/command_edit.html +0 -0
  63. {netbox_toolkit/templates/netbox_toolkit → netbox_toolkit_plugin/templates/netbox_toolkit_plugin}/commandlog_list.html +0 -0
  64. {netbox_toolkit → netbox_toolkit_plugin}/utils/__init__.py +0 -0
  65. {netbox_toolkit → netbox_toolkit_plugin}/utils/connection.py +0 -0
  66. {netbox_toolkit → netbox_toolkit_plugin}/utils/error_parser.py +0 -0
  67. {netbox_toolkit → netbox_toolkit_plugin}/utils/network.py +0 -0
  68. {netbox_toolkit_plugin-0.1.0.dist-info → netbox_toolkit_plugin-0.1.2.dist-info}/WHEEL +0 -0
  69. {netbox_toolkit_plugin-0.1.0.dist-info → netbox_toolkit_plugin-0.1.2.dist-info}/licenses/LICENSE +0 -0
@@ -7,38 +7,40 @@ from ..models import CommandLog
7
7
 
8
8
  class RateLimitingService:
9
9
  """Service for managing command execution rate limiting"""
10
-
10
+
11
11
  def __init__(self):
12
12
  """Initialize the rate limiting service with plugin settings"""
13
- self.plugin_settings = getattr(settings, 'PLUGINS_CONFIG', {}).get('netbox_toolkit', {})
14
-
13
+ self.plugin_settings = getattr(settings, "PLUGINS_CONFIG", {}).get(
14
+ "netbox_toolkit_plugin", {}
15
+ )
16
+
15
17
  def is_rate_limiting_enabled(self):
16
18
  """Check if rate limiting is enabled in plugin settings"""
17
- return self.plugin_settings.get('rate_limiting_enabled', False)
18
-
19
+ return self.plugin_settings.get("rate_limiting_enabled", False)
20
+
19
21
  def get_device_command_limit(self):
20
22
  """Get the maximum number of commands allowed per device within the time window"""
21
- return self.plugin_settings.get('device_command_limit', 10)
22
-
23
+ return self.plugin_settings.get("device_command_limit", 10)
24
+
23
25
  def get_time_window_minutes(self):
24
26
  """Get the time window in minutes for rate limiting"""
25
- return self.plugin_settings.get('time_window_minutes', 5)
26
-
27
+ return self.plugin_settings.get("time_window_minutes", 5)
28
+
27
29
  def get_bypass_users(self):
28
30
  """Get list of usernames that bypass rate limiting"""
29
- return self.plugin_settings.get('bypass_users', [])
30
-
31
+ return self.plugin_settings.get("bypass_users", [])
32
+
31
33
  def get_bypass_groups(self):
32
34
  """Get list of group names that bypass rate limiting"""
33
- return self.plugin_settings.get('bypass_groups', [])
34
-
35
+ return self.plugin_settings.get("bypass_groups", [])
36
+
35
37
  def user_bypasses_rate_limiting(self, user):
36
38
  """
37
39
  Check if a user bypasses rate limiting based on username or group membership
38
-
40
+
39
41
  Args:
40
42
  user: Django User object
41
-
43
+
42
44
  Returns:
43
45
  bool: True if user bypasses rate limiting, False otherwise
44
46
  """
@@ -46,49 +48,49 @@ class RateLimitingService:
46
48
  bypass_users = self.get_bypass_users()
47
49
  if user.username in bypass_users:
48
50
  return True
49
-
51
+
50
52
  # Check if user is in any bypass groups
51
53
  bypass_groups = self.get_bypass_groups()
52
54
  if bypass_groups:
53
- user_groups = user.groups.values_list('name', flat=True)
55
+ user_groups = user.groups.values_list("name", flat=True)
54
56
  if any(group in bypass_groups for group in user_groups):
55
57
  return True
56
-
58
+
57
59
  return False
58
-
60
+
59
61
  def get_recent_command_count(self, device, user=None):
60
62
  """
61
63
  Get the number of successful commands executed on a device within the time window
62
-
64
+
63
65
  Args:
64
66
  device: Device object
65
67
  user: Optional User object to count only commands by this user
66
-
68
+
67
69
  Returns:
68
70
  int: Number of recent successful commands
69
71
  """
70
72
  time_window = self.get_time_window_minutes()
71
73
  cutoff_time = timezone.now() - timedelta(minutes=time_window)
72
-
74
+
73
75
  query = CommandLog.objects.filter(
74
76
  device=device,
75
77
  execution_time__gte=cutoff_time,
76
- success=True # Only count successful commands
78
+ success=True, # Only count successful commands
77
79
  )
78
-
80
+
79
81
  if user:
80
82
  query = query.filter(username=user.username)
81
-
83
+
82
84
  return query.count()
83
-
85
+
84
86
  def check_rate_limit(self, device, user):
85
87
  """
86
88
  Check if a command execution would exceed rate limits
87
-
89
+
88
90
  Args:
89
91
  device: Device object to check rate limits for
90
92
  user: User object attempting to execute the command
91
-
93
+
92
94
  Returns:
93
95
  dict: {
94
96
  'allowed': bool,
@@ -101,128 +103,133 @@ class RateLimitingService:
101
103
  # If rate limiting is disabled, always allow
102
104
  if not self.is_rate_limiting_enabled():
103
105
  return {
104
- 'allowed': True,
105
- 'current_count': 0,
106
- 'limit': self.get_device_command_limit(),
107
- 'time_window_minutes': self.get_time_window_minutes(),
108
- 'reason': 'Rate limiting disabled'
106
+ "allowed": True,
107
+ "current_count": 0,
108
+ "limit": self.get_device_command_limit(),
109
+ "time_window_minutes": self.get_time_window_minutes(),
110
+ "reason": "Rate limiting disabled",
109
111
  }
110
-
112
+
111
113
  # If user bypasses rate limiting, always allow
112
114
  if self.user_bypasses_rate_limiting(user):
113
115
  return {
114
- 'allowed': True,
115
- 'current_count': 0,
116
- 'limit': self.get_device_command_limit(),
117
- 'time_window_minutes': self.get_time_window_minutes(),
118
- 'reason': 'User bypasses rate limiting'
116
+ "allowed": True,
117
+ "current_count": 0,
118
+ "limit": self.get_device_command_limit(),
119
+ "time_window_minutes": self.get_time_window_minutes(),
120
+ "reason": "User bypasses rate limiting",
119
121
  }
120
-
122
+
121
123
  # Check current command count
122
124
  current_count = self.get_recent_command_count(device)
123
125
  limit = self.get_device_command_limit()
124
126
  time_window = self.get_time_window_minutes()
125
-
127
+
126
128
  if current_count >= limit:
127
129
  return {
128
- 'allowed': False,
129
- 'current_count': current_count,
130
- 'limit': limit,
131
- 'time_window_minutes': time_window,
132
- 'reason': f'Rate limit exceeded: {current_count}/{limit} successful commands in last {time_window} minutes'
130
+ "allowed": False,
131
+ "current_count": current_count,
132
+ "limit": limit,
133
+ "time_window_minutes": time_window,
134
+ "reason": f"Rate limit exceeded: {current_count}/{limit} successful commands in last {time_window} minutes",
133
135
  }
134
-
136
+
135
137
  return {
136
- 'allowed': True,
137
- 'current_count': current_count,
138
- 'limit': limit,
139
- 'time_window_minutes': time_window,
140
- 'reason': 'Within rate limits'
138
+ "allowed": True,
139
+ "current_count": current_count,
140
+ "limit": limit,
141
+ "time_window_minutes": time_window,
142
+ "reason": "Within rate limits",
141
143
  }
142
-
144
+
143
145
  def get_rate_limit_status(self, device, user):
144
146
  """
145
147
  Get rate limit status for display in UI
146
-
148
+
147
149
  Args:
148
150
  device: Device object
149
151
  user: User object
150
-
152
+
151
153
  Returns:
152
154
  dict: Rate limit status information for UI display
153
155
  """
154
156
  if not self.is_rate_limiting_enabled():
155
- return {
156
- 'enabled': False,
157
- 'message': 'Rate limiting is disabled'
158
- }
159
-
157
+ return {"enabled": False, "message": "Rate limiting is disabled"}
158
+
160
159
  if self.user_bypasses_rate_limiting(user):
161
160
  return {
162
- 'enabled': True,
163
- 'bypassed': True,
164
- 'message': 'You have unlimited command execution (bypass enabled)'
161
+ "enabled": True,
162
+ "bypassed": True,
163
+ "message": "You have unlimited command execution (bypass enabled)",
165
164
  }
166
-
165
+
167
166
  current_count = self.get_recent_command_count(device)
168
167
  limit = self.get_device_command_limit()
169
168
  time_window = self.get_time_window_minutes()
170
169
  remaining = max(0, limit - current_count)
171
-
170
+
172
171
  # Determine status and appropriate message
173
172
  if current_count >= limit:
174
- status = 'exceeded'
173
+ status = "exceeded"
175
174
  # Get time until reset for exceeded status
176
175
  time_until_reset = self.get_time_until_reset(device)
177
176
  if time_until_reset:
178
177
  minutes_until_reset = int(time_until_reset.total_seconds() / 60) + 1
179
- message = f'Rate limit exceeded! ({current_count}/{limit} successful commands) - Try again in {minutes_until_reset} minutes'
178
+ message = f"Rate limit exceeded! ({current_count}/{limit} successful commands) - Try again in {minutes_until_reset} minutes"
180
179
  else:
181
- message = f'Rate limit exceeded! ({current_count}/{limit} successful commands in last {time_window} minutes)'
182
- elif remaining <= 2 and current_count < limit: # Only warning if we haven't exceeded the limit
183
- status = 'warning'
184
- message = f'{remaining} commands remaining ({current_count}/{limit} successful commands in last {time_window} minutes)'
180
+ message = f"Rate limit exceeded! ({current_count}/{limit} successful commands in last {time_window} minutes)"
181
+ elif (
182
+ remaining <= 2 and current_count < limit
183
+ ): # Only warning if we haven't exceeded the limit
184
+ status = "warning"
185
+ message = f"{remaining} commands remaining ({current_count}/{limit} successful commands in last {time_window} minutes)"
185
186
  else:
186
- status = 'normal'
187
- message = f'{remaining} commands remaining ({current_count}/{limit} successful commands in last {time_window} minutes)'
188
-
187
+ status = "normal"
188
+ message = f"{remaining} commands remaining ({current_count}/{limit} successful commands in last {time_window} minutes)"
189
+
189
190
  return {
190
- 'enabled': True,
191
- 'bypassed': False,
192
- 'current_count': current_count,
193
- 'limit': limit,
194
- 'remaining': remaining,
195
- 'time_window_minutes': time_window,
196
- 'status': status,
197
- 'message': message,
198
- 'is_exceeded': current_count >= limit,
199
- 'is_warning': remaining <= 2 and current_count < limit,
200
- 'time_until_reset': self.get_time_until_reset(device) if current_count >= limit else None,
191
+ "enabled": True,
192
+ "bypassed": False,
193
+ "current_count": current_count,
194
+ "limit": limit,
195
+ "remaining": remaining,
196
+ "time_window_minutes": time_window,
197
+ "status": status,
198
+ "message": message,
199
+ "is_exceeded": current_count >= limit,
200
+ "is_warning": remaining <= 2 and current_count < limit,
201
+ "time_until_reset": self.get_time_until_reset(device)
202
+ if current_count >= limit
203
+ else None,
201
204
  }
202
-
205
+
203
206
  def get_time_until_reset(self, device):
204
207
  """
205
208
  Get the time until the rate limit resets (oldest successful command in window expires)
206
-
209
+
207
210
  Args:
208
211
  device: Device object
209
-
212
+
210
213
  Returns:
211
214
  timedelta or None: Time until oldest successful command expires, None if no recent successful commands
212
215
  """
213
216
  time_window = self.get_time_window_minutes()
214
217
  cutoff_time = timezone.now() - timedelta(minutes=time_window)
215
-
216
- oldest_command = CommandLog.objects.filter(
217
- device=device,
218
- execution_time__gte=cutoff_time,
219
- success=True # Only consider successful commands
220
- ).order_by('execution_time').first()
221
-
218
+
219
+ oldest_command = (
220
+ CommandLog.objects.filter(
221
+ device=device,
222
+ execution_time__gte=cutoff_time,
223
+ success=True, # Only consider successful commands
224
+ )
225
+ .order_by("execution_time")
226
+ .first()
227
+ )
228
+
222
229
  if not oldest_command:
223
230
  return None
224
-
231
+
225
232
  reset_time = oldest_command.execution_time + timedelta(minutes=time_window)
226
233
  time_until_reset = reset_time - timezone.now()
227
-
234
+
228
235
  return time_until_reset if time_until_reset > timedelta(0) else None
@@ -0,0 +1,176 @@
1
+ """Configuration settings for the NetBox Toolkit plugin."""
2
+
3
+ from typing import Any
4
+
5
+ from django.conf import settings
6
+
7
+ # Plugin metadata - required by NetBox's plugin discovery system
8
+ __version__ = "0.1.1"
9
+ __author__ = "Andy Norwood"
10
+
11
+ # Make these available as module-level attributes for NetBox's plugin system
12
+ version = __version__
13
+ author = __author__
14
+ release_track = "stable" # or "beta", "alpha" - indicates the release track
15
+
16
+
17
+ class ToolkitSettings:
18
+ """Configuration class for toolkit settings."""
19
+
20
+ # Default connection timeouts
21
+ DEFAULT_TIMEOUTS = {
22
+ "socket": 15,
23
+ "transport": 15,
24
+ "ops": 30,
25
+ "banner": 15,
26
+ "auth": 15,
27
+ }
28
+
29
+ # Device-specific timeout overrides
30
+ DEVICE_TIMEOUTS = {
31
+ "catalyst": {
32
+ "socket": 20,
33
+ "transport": 20,
34
+ "ops": 45,
35
+ },
36
+ "nexus": {
37
+ "socket": 25,
38
+ "transport": 25,
39
+ "ops": 60,
40
+ },
41
+ }
42
+
43
+ # SSH transport options
44
+ SSH_TRANSPORT_OPTIONS = {
45
+ "disabled_algorithms": {
46
+ "kex": [], # Don't disable any key exchange methods
47
+ },
48
+ "allowed_kex": [
49
+ # Modern algorithms
50
+ "diffie-hellman-group-exchange-sha256",
51
+ "diffie-hellman-group16-sha512",
52
+ "diffie-hellman-group18-sha512",
53
+ "diffie-hellman-group14-sha256",
54
+ # Legacy algorithms for older devices
55
+ "diffie-hellman-group-exchange-sha1",
56
+ "diffie-hellman-group14-sha1",
57
+ "diffie-hellman-group1-sha1",
58
+ ],
59
+ }
60
+
61
+ # Netmiko configuration for fallback connections
62
+ NETMIKO_CONFIG = {
63
+ "banner_timeout": 20,
64
+ "auth_timeout": 20,
65
+ "global_delay_factor": 1,
66
+ "use_keys": False, # Disable SSH key authentication
67
+ "allow_agent": False, # Disable SSH agent
68
+ # Session logging (disabled by default)
69
+ "session_log": None,
70
+ # Connection options for legacy devices
71
+ "fast_cli": False, # Disable for older devices
72
+ "session_log_record_writes": False,
73
+ "session_log_file_mode": "write",
74
+ }
75
+
76
+ # Retry configuration
77
+ RETRY_CONFIG = {
78
+ "max_retries": 2,
79
+ "retry_delay": 1, # Reduced from 3s to 1s for faster fallback
80
+ "backoff_multiplier": 1.5, # Reduced from 2 to 1.5 for faster progression
81
+ }
82
+
83
+ # Fast connection test timeouts (for initial Scrapli viability testing)
84
+ FAST_TEST_TIMEOUTS = {
85
+ "socket": 8, # Reduced from 15s to 8s for faster detection
86
+ "transport": 8, # Reduced from 15s to 8s for faster detection
87
+ "ops": 15, # Keep ops timeout reasonable for actual commands
88
+ }
89
+
90
+ # Error patterns that should trigger immediate fallback to Netmiko
91
+ SCRAPLI_FAST_FAIL_PATTERNS = [
92
+ "No matching key exchange",
93
+ "No matching cipher",
94
+ "No matching MAC",
95
+ "connection not opened",
96
+ "Error reading SSH protocol banner",
97
+ "Connection refused",
98
+ "Operation timed out",
99
+ "SSH handshake failed",
100
+ "Protocol version not supported",
101
+ "Unable to connect to port 22",
102
+ "Name or service not known",
103
+ "Network is unreachable",
104
+ ]
105
+
106
+ # Platform mappings for better recognition
107
+ PLATFORM_ALIASES = {
108
+ "ios": "cisco_ios",
109
+ "iosxe": "cisco_ios",
110
+ "nxos": "cisco_nxos",
111
+ "iosxr": "cisco_iosxr",
112
+ "junos": "juniper_junos",
113
+ "eos": "arista_eos",
114
+ }
115
+
116
+ @classmethod
117
+ def get_fast_test_timeouts(cls) -> dict[str, int]:
118
+ """Get fast connection test timeouts for initial viability testing."""
119
+ return cls.FAST_TEST_TIMEOUTS.copy()
120
+
121
+ @classmethod
122
+ def should_fast_fail_to_netmiko(cls, error_message: str) -> bool:
123
+ """Check if error message indicates immediate fallback to Netmiko is needed."""
124
+ error_lower = error_message.lower()
125
+ return any(
126
+ pattern.lower() in error_lower for pattern in cls.SCRAPLI_FAST_FAIL_PATTERNS
127
+ )
128
+
129
+ @classmethod
130
+ def get_timeouts_for_device(cls, device_type_model: str = "") -> dict[str, int]:
131
+ """Get timeout configuration for a specific device type."""
132
+ timeouts = cls.DEFAULT_TIMEOUTS.copy()
133
+
134
+ if device_type_model:
135
+ model_lower = device_type_model.lower()
136
+ for device_keyword, custom_timeouts in cls.DEVICE_TIMEOUTS.items():
137
+ if device_keyword in model_lower:
138
+ timeouts.update(custom_timeouts)
139
+ break
140
+
141
+ return timeouts
142
+
143
+ @classmethod
144
+ def normalize_platform(cls, platform: str) -> str:
145
+ """Normalize platform name using aliases."""
146
+ if not platform:
147
+ return ""
148
+
149
+ platform_lower = platform.lower()
150
+ return cls.PLATFORM_ALIASES.get(platform_lower, platform_lower)
151
+
152
+ @classmethod
153
+ def get_ssh_options(cls) -> dict[str, Any]:
154
+ """Get SSH transport options."""
155
+ return cls.SSH_TRANSPORT_OPTIONS.copy()
156
+
157
+ @classmethod
158
+ def get_retry_config(cls) -> dict[str, int]:
159
+ """Get retry configuration."""
160
+ return cls.RETRY_CONFIG.copy()
161
+
162
+ @classmethod
163
+ def get_ssh_transport_options(cls) -> dict[str, Any]:
164
+ """Get SSH transport options for Scrapli."""
165
+ user_config = getattr(settings, "PLUGINS_CONFIG", {}).get(
166
+ "netbox_toolkit_plugin", {}
167
+ )
168
+ return {**cls.SSH_TRANSPORT_OPTIONS, **user_config.get("ssh_options", {})}
169
+
170
+ @classmethod
171
+ def get_netmiko_config(cls) -> dict[str, Any]:
172
+ """Get Netmiko configuration for fallback connections."""
173
+ user_config = getattr(settings, "PLUGINS_CONFIG", {}).get(
174
+ "netbox_toolkit_plugin", {}
175
+ )
176
+ return {**cls.NETMIKO_CONFIG, **user_config.get("netmiko", {})}
@@ -0,0 +1,51 @@
1
+ import django_tables2 as tables
2
+ from netbox.tables import NetBoxTable, columns
3
+ from .models import Command, CommandLog
4
+
5
+
6
+ class CommandTable(NetBoxTable):
7
+ name = tables.Column(
8
+ linkify=("plugins:netbox_toolkit_plugin:command_detail", [tables.A("pk")])
9
+ )
10
+ platform = tables.Column(linkify=True)
11
+ command_type = tables.Column()
12
+
13
+ class Meta(NetBoxTable.Meta):
14
+ model = Command
15
+ fields = ("pk", "id", "name", "platform", "command_type", "description")
16
+ default_columns = ("pk", "name", "platform", "command_type", "description")
17
+
18
+
19
+ class CommandLogTable(NetBoxTable):
20
+ command = tables.Column(
21
+ linkify=(
22
+ "plugins:netbox_toolkit_plugin:command_detail",
23
+ [tables.A("command.pk")],
24
+ )
25
+ )
26
+ device = tables.Column(linkify=True)
27
+ success = tables.BooleanColumn(verbose_name="Status", yesno=("Success", "Failed"))
28
+
29
+ # Remove actions column entirely
30
+ actions = False
31
+
32
+ class Meta(NetBoxTable.Meta):
33
+ model = CommandLog
34
+ fields = (
35
+ "pk",
36
+ "id",
37
+ "command",
38
+ "device",
39
+ "username",
40
+ "execution_time",
41
+ "success",
42
+ "execution_duration",
43
+ )
44
+ default_columns = (
45
+ "pk",
46
+ "command",
47
+ "device",
48
+ "username",
49
+ "execution_time",
50
+ "success",
51
+ )
@@ -0,0 +1,108 @@
1
+ {% extends 'generic/object.html' %}
2
+ {% load helpers %}
3
+ {% load static %}
4
+
5
+ {% block style %}
6
+ <link href="{% static 'netbox_toolkit_plugin/css/toolkit.css' %}" rel="stylesheet">
7
+ {% endblock %}
8
+
9
+ {% block buttons %}
10
+ {% if perms.netbox_toolkit_plugin.change_command %}
11
+ <a href="{% url 'plugins:netbox_toolkit_plugin:command_edit' pk=object.pk %}" class="btn btn-warning">
12
+ <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
13
+ </a>
14
+ {% endif %}
15
+ {% if perms.netbox_toolkit_plugin.delete_command %}
16
+ <a href="{% url 'plugins:netbox_toolkit_plugin:command_delete' pk=object.pk %}" class="btn btn-danger">
17
+ <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
18
+ </a>
19
+ {% endif %}
20
+ {% endblock %}
21
+
22
+ {% block content %}
23
+ <div class="row mb-3">
24
+ <div class="col col-md-6">
25
+ <div class="card">
26
+ <div class="card-header">
27
+ <h3 class="card-title">Command</h3>
28
+ </div>
29
+ <div class="card-body">
30
+ <table class="table table-hover attr-table">
31
+ <tr>
32
+ <th scope="row">Name</th>
33
+ <td>{{ object.name }}</td>
34
+ </tr>
35
+ <tr>
36
+ <th scope="row">Platform</th>
37
+ <td>{{ object.platform|linkify }}</td>
38
+ </tr>
39
+ <tr>
40
+ <th scope="row">Command Type</th>
41
+ <td>
42
+ {{ object.get_command_type_display }}
43
+ {% if can_execute %}
44
+ <span class="badge bg-success ms-2">
45
+ <i class="mdi mdi-check"></i> Executable
46
+ </span>
47
+ {% else %}
48
+ <span class="badge bg-secondary ms-2">
49
+ <i class="mdi mdi-lock"></i> No Execute Permission
50
+ </span>
51
+ {% endif %}
52
+ </td>
53
+ </tr>
54
+ <tr>
55
+ <th scope="row">Description</th>
56
+ <td>{{ object.description|placeholder }}</td>
57
+ </tr>
58
+ </table>
59
+ </div>
60
+ </div>
61
+ {% include 'inc/panels/custom_fields.html' %}
62
+ {% include 'inc/panels/tags.html' %}
63
+ </div>
64
+ <div class="col col-md-6">
65
+ <div class="card">
66
+ <div class="card-header">
67
+ <h3 class="card-title">Command Detail</h3>
68
+ </div>
69
+ <div class="card-body">
70
+ <pre>{{ object.command }}</pre>
71
+ </div>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ <div class="row">
76
+ <div class="col col-md-12">
77
+ <div class="card">
78
+ <div class="card-header">
79
+ <h3 class="card-title">Recent Command Logs</h3>
80
+ </div>
81
+ <div class="card-body">
82
+ <table class="table table-hover table-headings">
83
+ <thead>
84
+ <tr>
85
+ <th>Device</th>
86
+ <th>Username</th>
87
+ <th>Execution Time</th>
88
+ </tr>
89
+ </thead>
90
+ <tbody>
91
+ {% for log in object.logs.all|slice:":5" %}
92
+ <tr>
93
+ <td>{{ log.device|linkify }}</td>
94
+ <td>{{ log.username }}</td>
95
+ <td>{{ log.execution_time }}</td>
96
+ </tr>
97
+ {% empty %}
98
+ <tr>
99
+ <td colspan="3" class="text-muted">No command executions recorded</td>
100
+ </tr>
101
+ {% endfor %}
102
+ </tbody>
103
+ </table>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ {% endblock %}
@@ -0,0 +1,12 @@
1
+ {% extends 'generic/object_list.html' %}
2
+ {% load helpers %}
3
+
4
+ {% block title %}Commands{% endblock %}
5
+
6
+ {% block buttons %}
7
+ {% if perms.netbox_toolkit_plugin.add_command %}
8
+ <a href="{% url 'plugins:netbox_toolkit_plugin:command_add' %}" class="btn btn-primary">
9
+ <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Command
10
+ </a>
11
+ {% endif %}
12
+ {% endblock %}