ntermqt 0.1.3__py3-none-any.whl → 0.1.5__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.
- nterm/__main__.py +48 -0
- nterm/manager/tree.py +125 -42
- nterm/parser/__init__.py +0 -0
- nterm/parser/api_help_dialog.py +607 -0
- nterm/parser/ntc_download_dialog.py +372 -0
- nterm/parser/tfsm_engine.py +246 -0
- nterm/parser/tfsm_fire.py +237 -0
- nterm/parser/tfsm_fire_tester.py +2329 -0
- nterm/scripting/api.py +926 -19
- nterm/terminal/bridge.py +10 -0
- nterm/terminal/resources/terminal.html +9 -4
- nterm/terminal/resources/terminal.js +14 -1
- nterm/terminal/widget.py +73 -2
- nterm/theme/engine.py +45 -0
- nterm/theme/themes/nord_hybrid.yaml +43 -0
- nterm/vault/store.py +3 -3
- {ntermqt-0.1.3.dist-info → ntermqt-0.1.5.dist-info}/METADATA +43 -15
- {ntermqt-0.1.3.dist-info → ntermqt-0.1.5.dist-info}/RECORD +21 -14
- {ntermqt-0.1.3.dist-info → ntermqt-0.1.5.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.3.dist-info → ntermqt-0.1.5.dist-info}/entry_points.txt +0 -0
- {ntermqt-0.1.3.dist-info → ntermqt-0.1.5.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,2329 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
TextFSM Template Tester - Enhanced Edition
|
|
4
|
+
Debug tool for testing template matching, manual parsing, and template management
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Database-driven template testing with auto-scoring
|
|
8
|
+
- Manual TextFSM template testing (no database required)
|
|
9
|
+
- Full CRUD interface for tfsm_templates.db
|
|
10
|
+
- Light/Dark/Cyber theme support
|
|
11
|
+
|
|
12
|
+
Author: Scott Peterman
|
|
13
|
+
License: MIT
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
import json
|
|
18
|
+
import sqlite3
|
|
19
|
+
import hashlib
|
|
20
|
+
import traceback
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from typing import Optional, List, Dict, Any
|
|
24
|
+
|
|
25
|
+
from PyQt6.QtWidgets import (
|
|
26
|
+
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
27
|
+
QTextEdit, QLineEdit, QPushButton, QLabel, QSplitter, QTableWidget,
|
|
28
|
+
QTableWidgetItem, QTabWidget, QGroupBox, QSpinBox, QCheckBox,
|
|
29
|
+
QFileDialog, QMessageBox, QComboBox, QDialog, QDialogButtonBox,
|
|
30
|
+
QFormLayout, QHeaderView, QAbstractItemView, QMenu, QInputDialog,
|
|
31
|
+
QStatusBar, QToolBar, QFrame
|
|
32
|
+
)
|
|
33
|
+
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize
|
|
34
|
+
from PyQt6.QtGui import QFont, QAction, QIcon, QColor, QPalette, QShortcut, QKeySequence
|
|
35
|
+
|
|
36
|
+
import textfsm
|
|
37
|
+
import io
|
|
38
|
+
from collections import defaultdict
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_package_db_path() -> Path:
|
|
42
|
+
"""Database is in same directory as this module."""
|
|
43
|
+
return Path(__file__).parent / "tfsm_templates.db"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def find_database(db_path: Optional[str] = None) -> Optional[Path]:
|
|
47
|
+
"""Find database - explicit path first, then package location."""
|
|
48
|
+
|
|
49
|
+
def is_valid_db(path: Path) -> bool:
|
|
50
|
+
return path.exists() and path.is_file() and path.stat().st_size > 0
|
|
51
|
+
|
|
52
|
+
if db_path:
|
|
53
|
+
p = Path(db_path)
|
|
54
|
+
return p if is_valid_db(p) else None
|
|
55
|
+
|
|
56
|
+
package_db = get_package_db_path()
|
|
57
|
+
return package_db if is_valid_db(package_db) else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Try to import requests for NTC GitHub download
|
|
61
|
+
REQUESTS_AVAILABLE = False
|
|
62
|
+
try:
|
|
63
|
+
import requests
|
|
64
|
+
|
|
65
|
+
REQUESTS_AVAILABLE = True
|
|
66
|
+
except ImportError:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
# Try to import the engine, but don't fail if not available (manual mode still works)
|
|
70
|
+
TFSM_ENGINE_AVAILABLE = False
|
|
71
|
+
try:
|
|
72
|
+
from tfsm_fire import TextFSMAutoEngine
|
|
73
|
+
|
|
74
|
+
TFSM_ENGINE_AVAILABLE = True
|
|
75
|
+
except ImportError:
|
|
76
|
+
try:
|
|
77
|
+
from .tfsm_fire import TextFSMAutoEngine
|
|
78
|
+
|
|
79
|
+
TFSM_ENGINE_AVAILABLE = True
|
|
80
|
+
except ImportError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
# =============================================================================
|
|
84
|
+
# NTC TEMPLATES GITHUB DOWNLOAD
|
|
85
|
+
# =============================================================================
|
|
86
|
+
|
|
87
|
+
GITHUB_API_URL = "https://api.github.com/repos/networktocode/ntc-templates/contents/ntc_templates/templates"
|
|
88
|
+
GITHUB_RAW_BASE = "https://raw.githubusercontent.com/networktocode/ntc-templates/master/ntc_templates/templates"
|
|
89
|
+
|
|
90
|
+
VENDOR_PREFIXES = [
|
|
91
|
+
'cisco', 'arista', 'juniper', 'hp', 'dell', 'paloalto', 'fortinet',
|
|
92
|
+
'brocade', 'extreme', 'huawei', 'mikrotik', 'ubiquiti', 'vmware',
|
|
93
|
+
'checkpoint', 'alcatel', 'avaya', 'ruckus', 'f5', 'a10', 'linux',
|
|
94
|
+
'yamaha', 'zyxel', 'enterasys', 'adtran', 'ciena', 'nokia', 'watchguard'
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def extract_platform(filename: str) -> str:
|
|
99
|
+
"""Extract platform name from template filename."""
|
|
100
|
+
name = filename.replace('.textfsm', '')
|
|
101
|
+
parts = name.split('_')
|
|
102
|
+
if len(parts) >= 2 and parts[0] in VENDOR_PREFIXES:
|
|
103
|
+
return f"{parts[0]}_{parts[1]}"
|
|
104
|
+
if parts[0] in VENDOR_PREFIXES:
|
|
105
|
+
return parts[0]
|
|
106
|
+
return parts[0]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class NTCDownloadWorker(QThread):
|
|
110
|
+
"""Worker thread for downloading NTC templates"""
|
|
111
|
+
progress = pyqtSignal(int, int, str) # current, total, status
|
|
112
|
+
finished = pyqtSignal(dict) # stats dict
|
|
113
|
+
error = pyqtSignal(str)
|
|
114
|
+
|
|
115
|
+
def __init__(self, platforms: list, db_path: str, replace: bool = False):
|
|
116
|
+
super().__init__()
|
|
117
|
+
self.platforms = platforms
|
|
118
|
+
self.db_path = db_path or str(get_package_db_path())
|
|
119
|
+
self.replace = replace
|
|
120
|
+
self.templates_to_download = []
|
|
121
|
+
|
|
122
|
+
def run(self):
|
|
123
|
+
try:
|
|
124
|
+
# Fetch template list
|
|
125
|
+
response = requests.get(GITHUB_API_URL, timeout=30)
|
|
126
|
+
response.raise_for_status()
|
|
127
|
+
|
|
128
|
+
files = response.json()
|
|
129
|
+
all_templates = [f for f in files if f['name'].endswith('.textfsm')]
|
|
130
|
+
|
|
131
|
+
# Group by platform
|
|
132
|
+
platforms_map = defaultdict(list)
|
|
133
|
+
for t in all_templates:
|
|
134
|
+
platform = extract_platform(t['name'])
|
|
135
|
+
platforms_map[platform].append(t)
|
|
136
|
+
|
|
137
|
+
# Filter to selected platforms
|
|
138
|
+
for platform in self.platforms:
|
|
139
|
+
if platform in platforms_map:
|
|
140
|
+
self.templates_to_download.extend(platforms_map[platform])
|
|
141
|
+
|
|
142
|
+
if not self.templates_to_download:
|
|
143
|
+
self.finished.emit({'imported': 0, 'updated': 0, 'skipped': 0, 'errors': 0})
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
# Connect to database
|
|
147
|
+
conn = sqlite3.connect(self.db_path)
|
|
148
|
+
conn.execute("""
|
|
149
|
+
CREATE TABLE IF NOT EXISTS templates (
|
|
150
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
151
|
+
cli_command TEXT UNIQUE,
|
|
152
|
+
cli_content TEXT,
|
|
153
|
+
textfsm_content TEXT,
|
|
154
|
+
textfsm_hash TEXT,
|
|
155
|
+
source TEXT,
|
|
156
|
+
created TEXT
|
|
157
|
+
)
|
|
158
|
+
""")
|
|
159
|
+
cursor = conn.cursor()
|
|
160
|
+
|
|
161
|
+
stats = {'imported': 0, 'updated': 0, 'skipped': 0, 'errors': 0}
|
|
162
|
+
total = len(self.templates_to_download)
|
|
163
|
+
|
|
164
|
+
for i, template in enumerate(self.templates_to_download, 1):
|
|
165
|
+
name = template['name']
|
|
166
|
+
cli_command = name.replace('.textfsm', '')
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
# Download content
|
|
170
|
+
url = f"{GITHUB_RAW_BASE}/{name}"
|
|
171
|
+
resp = requests.get(url, timeout=30)
|
|
172
|
+
resp.raise_for_status()
|
|
173
|
+
content = resp.text
|
|
174
|
+
|
|
175
|
+
textfsm_hash = hashlib.md5(content.encode()).hexdigest()
|
|
176
|
+
created = datetime.now().isoformat()
|
|
177
|
+
|
|
178
|
+
# Check if exists
|
|
179
|
+
cursor.execute("SELECT textfsm_hash FROM templates WHERE cli_command = ?", (cli_command,))
|
|
180
|
+
existing = cursor.fetchone()
|
|
181
|
+
|
|
182
|
+
if existing:
|
|
183
|
+
if self.replace and existing[0] != textfsm_hash:
|
|
184
|
+
cursor.execute("""
|
|
185
|
+
UPDATE templates
|
|
186
|
+
SET textfsm_content = ?, textfsm_hash = ?, source = ?, created = ?
|
|
187
|
+
WHERE cli_command = ?
|
|
188
|
+
""", (content, textfsm_hash, "ntc-templates", created, cli_command))
|
|
189
|
+
stats['updated'] += 1
|
|
190
|
+
status = "U"
|
|
191
|
+
else:
|
|
192
|
+
stats['skipped'] += 1
|
|
193
|
+
status = "."
|
|
194
|
+
else:
|
|
195
|
+
cursor.execute("""
|
|
196
|
+
INSERT INTO templates (cli_command, cli_content, textfsm_content, textfsm_hash, source, created)
|
|
197
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
198
|
+
""", (cli_command, "", content, textfsm_hash, "ntc-templates", created))
|
|
199
|
+
stats['imported'] += 1
|
|
200
|
+
status = "+"
|
|
201
|
+
|
|
202
|
+
self.progress.emit(i, total, f"{status} {cli_command}")
|
|
203
|
+
|
|
204
|
+
except Exception as e:
|
|
205
|
+
stats['errors'] += 1
|
|
206
|
+
self.progress.emit(i, total, f"E {cli_command}: {str(e)[:30]}")
|
|
207
|
+
|
|
208
|
+
conn.commit()
|
|
209
|
+
conn.close()
|
|
210
|
+
self.finished.emit(stats)
|
|
211
|
+
|
|
212
|
+
except Exception as e:
|
|
213
|
+
traceback.print_exc()
|
|
214
|
+
self.error.emit(str(e))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class NTCDownloadDialog(QDialog):
|
|
218
|
+
"""Dialog for selecting and downloading NTC templates from GitHub"""
|
|
219
|
+
|
|
220
|
+
def __init__(self, parent=None, db_path: str = "tfsm_templates.db"):
|
|
221
|
+
super().__init__(parent)
|
|
222
|
+
self.db_path = db_path
|
|
223
|
+
self.platforms = {}
|
|
224
|
+
self.setWindowTitle("Download NTC Templates from GitHub")
|
|
225
|
+
self.setMinimumSize(600, 500)
|
|
226
|
+
self.setup_ui()
|
|
227
|
+
|
|
228
|
+
def setup_ui(self):
|
|
229
|
+
layout = QVBoxLayout(self)
|
|
230
|
+
|
|
231
|
+
# Info label
|
|
232
|
+
info = QLabel("Download TextFSM templates directly from networktocode/ntc-templates GitHub repository.")
|
|
233
|
+
info.setWordWrap(True)
|
|
234
|
+
layout.addWidget(info)
|
|
235
|
+
|
|
236
|
+
# Fetch button
|
|
237
|
+
fetch_layout = QHBoxLayout()
|
|
238
|
+
self.fetch_btn = QPushButton("Fetch Available Platforms")
|
|
239
|
+
self.fetch_btn.clicked.connect(self.fetch_platforms)
|
|
240
|
+
fetch_layout.addWidget(self.fetch_btn)
|
|
241
|
+
fetch_layout.addStretch()
|
|
242
|
+
layout.addLayout(fetch_layout)
|
|
243
|
+
|
|
244
|
+
# Platform list
|
|
245
|
+
list_group = QGroupBox("Available Platforms")
|
|
246
|
+
list_layout = QVBoxLayout(list_group)
|
|
247
|
+
|
|
248
|
+
# Select all / none buttons
|
|
249
|
+
select_layout = QHBoxLayout()
|
|
250
|
+
select_all_btn = QPushButton("Select All")
|
|
251
|
+
select_all_btn.setProperty("secondary", True)
|
|
252
|
+
select_all_btn.clicked.connect(self.select_all)
|
|
253
|
+
select_layout.addWidget(select_all_btn)
|
|
254
|
+
|
|
255
|
+
select_none_btn = QPushButton("Select None")
|
|
256
|
+
select_none_btn.setProperty("secondary", True)
|
|
257
|
+
select_none_btn.clicked.connect(self.select_none)
|
|
258
|
+
select_layout.addWidget(select_none_btn)
|
|
259
|
+
|
|
260
|
+
select_layout.addStretch()
|
|
261
|
+
|
|
262
|
+
self.status_label = QLabel("")
|
|
263
|
+
select_layout.addWidget(self.status_label)
|
|
264
|
+
list_layout.addLayout(select_layout)
|
|
265
|
+
|
|
266
|
+
# Platform table with checkboxes
|
|
267
|
+
self.platform_table = QTableWidget()
|
|
268
|
+
self.platform_table.setColumnCount(3)
|
|
269
|
+
self.platform_table.setHorizontalHeaderLabels(["Select", "Platform", "Templates"])
|
|
270
|
+
self.platform_table.horizontalHeader().setStretchLastSection(True)
|
|
271
|
+
self.platform_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
|
272
|
+
self.platform_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
273
|
+
list_layout.addWidget(self.platform_table)
|
|
274
|
+
|
|
275
|
+
layout.addWidget(list_group)
|
|
276
|
+
|
|
277
|
+
# Options
|
|
278
|
+
options_layout = QHBoxLayout()
|
|
279
|
+
self.replace_check = QCheckBox("Replace existing templates if content changed")
|
|
280
|
+
options_layout.addWidget(self.replace_check)
|
|
281
|
+
options_layout.addStretch()
|
|
282
|
+
layout.addLayout(options_layout)
|
|
283
|
+
|
|
284
|
+
# Progress
|
|
285
|
+
self.progress_label = QLabel("")
|
|
286
|
+
layout.addWidget(self.progress_label)
|
|
287
|
+
|
|
288
|
+
# Buttons
|
|
289
|
+
btn_layout = QHBoxLayout()
|
|
290
|
+
btn_layout.addStretch()
|
|
291
|
+
|
|
292
|
+
self.download_btn = QPushButton("Download Selected")
|
|
293
|
+
self.download_btn.clicked.connect(self.start_download)
|
|
294
|
+
self.download_btn.setEnabled(False)
|
|
295
|
+
btn_layout.addWidget(self.download_btn)
|
|
296
|
+
|
|
297
|
+
close_btn = QPushButton("Close")
|
|
298
|
+
close_btn.setProperty("secondary", True)
|
|
299
|
+
close_btn.clicked.connect(self.accept)
|
|
300
|
+
btn_layout.addWidget(close_btn)
|
|
301
|
+
|
|
302
|
+
layout.addLayout(btn_layout)
|
|
303
|
+
|
|
304
|
+
def fetch_platforms(self):
|
|
305
|
+
"""Fetch available platforms from GitHub"""
|
|
306
|
+
if not REQUESTS_AVAILABLE:
|
|
307
|
+
QMessageBox.critical(self, "Error", "requests library not available.\nInstall with: pip install requests")
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
self.fetch_btn.setEnabled(False)
|
|
311
|
+
self.status_label.setText("Fetching from GitHub...")
|
|
312
|
+
QApplication.processEvents()
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
response = requests.get(GITHUB_API_URL, timeout=30)
|
|
316
|
+
response.raise_for_status()
|
|
317
|
+
|
|
318
|
+
files = response.json()
|
|
319
|
+
templates = [f for f in files if f['name'].endswith('.textfsm')]
|
|
320
|
+
|
|
321
|
+
# Group by platform
|
|
322
|
+
self.platforms = defaultdict(list)
|
|
323
|
+
for t in templates:
|
|
324
|
+
platform = extract_platform(t['name'])
|
|
325
|
+
self.platforms[platform].append(t)
|
|
326
|
+
|
|
327
|
+
# Populate table
|
|
328
|
+
self.platform_table.setRowCount(len(self.platforms))
|
|
329
|
+
for row, (platform, tmpl_list) in enumerate(sorted(self.platforms.items(), key=lambda x: -len(x[1]))):
|
|
330
|
+
# Checkbox
|
|
331
|
+
checkbox = QCheckBox()
|
|
332
|
+
self.platform_table.setCellWidget(row, 0, checkbox)
|
|
333
|
+
|
|
334
|
+
# Platform name
|
|
335
|
+
self.platform_table.setItem(row, 1, QTableWidgetItem(platform))
|
|
336
|
+
|
|
337
|
+
# Template count
|
|
338
|
+
self.platform_table.setItem(row, 2, QTableWidgetItem(str(len(tmpl_list))))
|
|
339
|
+
|
|
340
|
+
self.status_label.setText(f"Found {len(templates)} templates across {len(self.platforms)} platforms")
|
|
341
|
+
self.download_btn.setEnabled(True)
|
|
342
|
+
|
|
343
|
+
except Exception as e:
|
|
344
|
+
traceback.print_exc()
|
|
345
|
+
QMessageBox.critical(self, "Error", f"Failed to fetch platforms:\n{str(e)}")
|
|
346
|
+
self.status_label.setText("Fetch failed")
|
|
347
|
+
|
|
348
|
+
self.fetch_btn.setEnabled(True)
|
|
349
|
+
|
|
350
|
+
def select_all(self):
|
|
351
|
+
for row in range(self.platform_table.rowCount()):
|
|
352
|
+
checkbox = self.platform_table.cellWidget(row, 0)
|
|
353
|
+
if checkbox:
|
|
354
|
+
checkbox.setChecked(True)
|
|
355
|
+
|
|
356
|
+
def select_none(self):
|
|
357
|
+
for row in range(self.platform_table.rowCount()):
|
|
358
|
+
checkbox = self.platform_table.cellWidget(row, 0)
|
|
359
|
+
if checkbox:
|
|
360
|
+
checkbox.setChecked(False)
|
|
361
|
+
|
|
362
|
+
def get_selected_platforms(self) -> list:
|
|
363
|
+
selected = []
|
|
364
|
+
for row in range(self.platform_table.rowCount()):
|
|
365
|
+
checkbox = self.platform_table.cellWidget(row, 0)
|
|
366
|
+
if checkbox and checkbox.isChecked():
|
|
367
|
+
item = self.platform_table.item(row, 1)
|
|
368
|
+
if item:
|
|
369
|
+
selected.append(item.text())
|
|
370
|
+
return selected
|
|
371
|
+
|
|
372
|
+
def start_download(self):
|
|
373
|
+
selected = self.get_selected_platforms()
|
|
374
|
+
if not selected:
|
|
375
|
+
QMessageBox.warning(self, "Warning", "Please select at least one platform")
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
self.download_btn.setEnabled(False)
|
|
379
|
+
self.fetch_btn.setEnabled(False)
|
|
380
|
+
self.progress_label.setText("Starting download...")
|
|
381
|
+
|
|
382
|
+
self.worker = NTCDownloadWorker(selected, self.db_path, self.replace_check.isChecked())
|
|
383
|
+
self.worker.progress.connect(self.update_progress)
|
|
384
|
+
self.worker.finished.connect(self.download_finished)
|
|
385
|
+
self.worker.error.connect(self.download_error)
|
|
386
|
+
self.worker.start()
|
|
387
|
+
|
|
388
|
+
def update_progress(self, current: int, total: int, status: str):
|
|
389
|
+
self.progress_label.setText(f"[{current}/{total}] {status}")
|
|
390
|
+
|
|
391
|
+
def download_finished(self, stats: dict):
|
|
392
|
+
self.download_btn.setEnabled(True)
|
|
393
|
+
self.fetch_btn.setEnabled(True)
|
|
394
|
+
|
|
395
|
+
msg = f"Download Complete!\n\nImported: {stats['imported']}\nUpdated: {stats['updated']}\nSkipped: {stats['skipped']}\nErrors: {stats['errors']}"
|
|
396
|
+
self.progress_label.setText(
|
|
397
|
+
f"Done: {stats['imported']} imported, {stats['updated']} updated, {stats['skipped']} skipped")
|
|
398
|
+
QMessageBox.information(self, "Download Complete", msg)
|
|
399
|
+
|
|
400
|
+
def download_error(self, error: str):
|
|
401
|
+
self.download_btn.setEnabled(True)
|
|
402
|
+
self.fetch_btn.setEnabled(True)
|
|
403
|
+
self.progress_label.setText("Download failed")
|
|
404
|
+
QMessageBox.critical(self, "Error", f"Download failed:\n{error}")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# =============================================================================
|
|
408
|
+
# THEME DEFINITIONS
|
|
409
|
+
# =============================================================================
|
|
410
|
+
|
|
411
|
+
THEMES = {
|
|
412
|
+
"light": {
|
|
413
|
+
"name": "Light",
|
|
414
|
+
"window_bg": "#FAFAFA",
|
|
415
|
+
"surface_bg": "#FFFFFF",
|
|
416
|
+
"surface_alt": "#F5F5F5",
|
|
417
|
+
"primary": "#6D4C41",
|
|
418
|
+
"primary_hover": "#5D4037",
|
|
419
|
+
"primary_text": "#FFFFFF",
|
|
420
|
+
"text": "#212121",
|
|
421
|
+
"text_secondary": "#757575",
|
|
422
|
+
"border": "#E0E0E0",
|
|
423
|
+
"input_bg": "#FFFFFF",
|
|
424
|
+
"input_border": "#BDBDBD",
|
|
425
|
+
"input_focus": "#6D4C41",
|
|
426
|
+
"success": "#4CAF50",
|
|
427
|
+
"warning": "#FF9800",
|
|
428
|
+
"error": "#F44336",
|
|
429
|
+
"table_header": "#EFEBE9",
|
|
430
|
+
"table_alt_row": "#FAFAFA",
|
|
431
|
+
"selection": "#D7CCC8",
|
|
432
|
+
"scrollbar_bg": "#F5F5F5",
|
|
433
|
+
"scrollbar_handle": "#BDBDBD",
|
|
434
|
+
"code_bg": "#F5F5F5",
|
|
435
|
+
},
|
|
436
|
+
"dark": {
|
|
437
|
+
"name": "Dark",
|
|
438
|
+
"window_bg": "#1E1E1E",
|
|
439
|
+
"surface_bg": "#252526",
|
|
440
|
+
"surface_alt": "#2D2D30",
|
|
441
|
+
"primary": "#8B6914",
|
|
442
|
+
"primary_hover": "#A67C00",
|
|
443
|
+
"primary_text": "#FFFFFF",
|
|
444
|
+
"text": "#D4D4D4",
|
|
445
|
+
"text_secondary": "#808080",
|
|
446
|
+
"border": "#3E3E42",
|
|
447
|
+
"input_bg": "#3C3C3C",
|
|
448
|
+
"input_border": "#3E3E42",
|
|
449
|
+
"input_focus": "#8B6914",
|
|
450
|
+
"success": "#6A9955",
|
|
451
|
+
"warning": "#CE9178",
|
|
452
|
+
"error": "#F14C4C",
|
|
453
|
+
"table_header": "#2D2D30",
|
|
454
|
+
"table_alt_row": "#2A2A2A",
|
|
455
|
+
"selection": "#264F78",
|
|
456
|
+
"scrollbar_bg": "#1E1E1E",
|
|
457
|
+
"scrollbar_handle": "#424242",
|
|
458
|
+
"code_bg": "#1E1E1E",
|
|
459
|
+
},
|
|
460
|
+
"cyber": {
|
|
461
|
+
"name": "Cyber",
|
|
462
|
+
"window_bg": "#0A0E14",
|
|
463
|
+
"surface_bg": "#0D1117",
|
|
464
|
+
"surface_alt": "#161B22",
|
|
465
|
+
"primary": "#00D4AA",
|
|
466
|
+
"primary_hover": "#00F5C4",
|
|
467
|
+
"primary_text": "#0A0E14",
|
|
468
|
+
"text": "#00D4AA",
|
|
469
|
+
"text_secondary": "#00A080",
|
|
470
|
+
"border": "#00D4AA40",
|
|
471
|
+
"input_bg": "#0D1117",
|
|
472
|
+
"input_border": "#00D4AA60",
|
|
473
|
+
"input_focus": "#00D4AA",
|
|
474
|
+
"success": "#00D4AA",
|
|
475
|
+
"warning": "#FFB800",
|
|
476
|
+
"error": "#FF3366",
|
|
477
|
+
"table_header": "#161B22",
|
|
478
|
+
"table_alt_row": "#0D1117",
|
|
479
|
+
"selection": "#00D4AA30",
|
|
480
|
+
"scrollbar_bg": "#161B22",
|
|
481
|
+
"scrollbar_handle": "#00D4AA",
|
|
482
|
+
"code_bg": "#0A0E14",
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def get_stylesheet(theme_name: str) -> str:
|
|
488
|
+
"""Generate stylesheet for the given theme"""
|
|
489
|
+
t = THEMES.get(theme_name, THEMES["light"])
|
|
490
|
+
|
|
491
|
+
return f"""
|
|
492
|
+
QMainWindow {{
|
|
493
|
+
background-color: {t['window_bg']};
|
|
494
|
+
color: {t['text']};
|
|
495
|
+
}}
|
|
496
|
+
|
|
497
|
+
QMainWindow > QWidget {{
|
|
498
|
+
background-color: {t['window_bg']};
|
|
499
|
+
}}
|
|
500
|
+
|
|
501
|
+
QDialog {{
|
|
502
|
+
background-color: {t['window_bg']};
|
|
503
|
+
color: {t['text']};
|
|
504
|
+
}}
|
|
505
|
+
|
|
506
|
+
QWidget {{
|
|
507
|
+
color: {t['text']};
|
|
508
|
+
font-family: 'Segoe UI', 'SF Pro Display', sans-serif;
|
|
509
|
+
font-size: 13px;
|
|
510
|
+
}}
|
|
511
|
+
|
|
512
|
+
QSplitter {{
|
|
513
|
+
background-color: {t['window_bg']};
|
|
514
|
+
}}
|
|
515
|
+
|
|
516
|
+
QTabWidget {{
|
|
517
|
+
background-color: {t['window_bg']};
|
|
518
|
+
}}
|
|
519
|
+
|
|
520
|
+
QGroupBox {{
|
|
521
|
+
background-color: {t['surface_bg']};
|
|
522
|
+
border: 1px solid {t['border']};
|
|
523
|
+
border-radius: 8px;
|
|
524
|
+
margin-top: 12px;
|
|
525
|
+
padding: 16px;
|
|
526
|
+
padding-top: 24px;
|
|
527
|
+
font-weight: 600;
|
|
528
|
+
}}
|
|
529
|
+
|
|
530
|
+
QGroupBox::title {{
|
|
531
|
+
subcontrol-origin: margin;
|
|
532
|
+
subcontrol-position: top left;
|
|
533
|
+
left: 12px;
|
|
534
|
+
padding: 0 8px;
|
|
535
|
+
color: {t['text']};
|
|
536
|
+
background-color: {t['surface_bg']};
|
|
537
|
+
}}
|
|
538
|
+
|
|
539
|
+
QTabWidget::pane {{
|
|
540
|
+
background-color: {t['surface_bg']};
|
|
541
|
+
border: 1px solid {t['border']};
|
|
542
|
+
border-radius: 8px;
|
|
543
|
+
padding: 8px;
|
|
544
|
+
}}
|
|
545
|
+
|
|
546
|
+
QTabBar::tab {{
|
|
547
|
+
background-color: {t['surface_alt']};
|
|
548
|
+
color: {t['text_secondary']};
|
|
549
|
+
border: 1px solid {t['border']};
|
|
550
|
+
border-bottom: none;
|
|
551
|
+
border-top-left-radius: 6px;
|
|
552
|
+
border-top-right-radius: 6px;
|
|
553
|
+
padding: 8px 16px;
|
|
554
|
+
margin-right: 2px;
|
|
555
|
+
}}
|
|
556
|
+
|
|
557
|
+
QTabBar::tab:selected {{
|
|
558
|
+
background-color: {t['surface_bg']};
|
|
559
|
+
color: {t['primary']};
|
|
560
|
+
border-bottom: 2px solid {t['primary']};
|
|
561
|
+
}}
|
|
562
|
+
|
|
563
|
+
QTabBar::tab:hover:!selected {{
|
|
564
|
+
background-color: {t['selection']};
|
|
565
|
+
}}
|
|
566
|
+
|
|
567
|
+
QPushButton {{
|
|
568
|
+
background-color: {t['primary']};
|
|
569
|
+
color: {t['primary_text']};
|
|
570
|
+
border: none;
|
|
571
|
+
border-radius: 6px;
|
|
572
|
+
padding: 8px 16px;
|
|
573
|
+
font-weight: 600;
|
|
574
|
+
}}
|
|
575
|
+
|
|
576
|
+
QPushButton:hover {{
|
|
577
|
+
background-color: {t['primary_hover']};
|
|
578
|
+
}}
|
|
579
|
+
|
|
580
|
+
QPushButton:pressed {{
|
|
581
|
+
background-color: {t['primary']};
|
|
582
|
+
}}
|
|
583
|
+
|
|
584
|
+
QPushButton:disabled {{
|
|
585
|
+
background-color: {t['border']};
|
|
586
|
+
color: {t['text_secondary']};
|
|
587
|
+
}}
|
|
588
|
+
|
|
589
|
+
QPushButton[secondary="true"] {{
|
|
590
|
+
background-color: {t['surface_alt']};
|
|
591
|
+
color: {t['text']};
|
|
592
|
+
border: 1px solid {t['border']};
|
|
593
|
+
}}
|
|
594
|
+
|
|
595
|
+
QPushButton[secondary="true"]:hover {{
|
|
596
|
+
background-color: {t['selection']};
|
|
597
|
+
border-color: {t['primary']};
|
|
598
|
+
}}
|
|
599
|
+
|
|
600
|
+
QPushButton[danger="true"] {{
|
|
601
|
+
background-color: {t['error']};
|
|
602
|
+
}}
|
|
603
|
+
|
|
604
|
+
QPushButton[danger="true"]:hover {{
|
|
605
|
+
background-color: {t['error']};
|
|
606
|
+
}}
|
|
607
|
+
|
|
608
|
+
QLineEdit, QSpinBox {{
|
|
609
|
+
background-color: {t['input_bg']};
|
|
610
|
+
color: {t['text']};
|
|
611
|
+
border: 1px solid {t['input_border']};
|
|
612
|
+
border-radius: 6px;
|
|
613
|
+
padding: 8px 12px;
|
|
614
|
+
}}
|
|
615
|
+
|
|
616
|
+
QLineEdit:focus, QSpinBox:focus {{
|
|
617
|
+
border-color: {t['input_focus']};
|
|
618
|
+
border-width: 2px;
|
|
619
|
+
}}
|
|
620
|
+
|
|
621
|
+
QTextEdit {{
|
|
622
|
+
background-color: {t['code_bg']};
|
|
623
|
+
color: {t['text']};
|
|
624
|
+
border: 1px solid {t['border']};
|
|
625
|
+
border-radius: 6px;
|
|
626
|
+
padding: 8px;
|
|
627
|
+
font-family: 'Fira Code', 'Consolas', 'Monaco', monospace;
|
|
628
|
+
font-size: 12px;
|
|
629
|
+
}}
|
|
630
|
+
|
|
631
|
+
QTextEdit:focus {{
|
|
632
|
+
border-color: {t['input_focus']};
|
|
633
|
+
}}
|
|
634
|
+
|
|
635
|
+
QComboBox {{
|
|
636
|
+
background-color: {t['input_bg']};
|
|
637
|
+
color: {t['text']};
|
|
638
|
+
border: 1px solid {t['input_border']};
|
|
639
|
+
border-radius: 6px;
|
|
640
|
+
padding: 8px 12px;
|
|
641
|
+
min-width: 120px;
|
|
642
|
+
}}
|
|
643
|
+
|
|
644
|
+
QComboBox::drop-down {{
|
|
645
|
+
border: none;
|
|
646
|
+
width: 24px;
|
|
647
|
+
}}
|
|
648
|
+
|
|
649
|
+
QComboBox::down-arrow {{
|
|
650
|
+
image: none;
|
|
651
|
+
border-left: 5px solid transparent;
|
|
652
|
+
border-right: 5px solid transparent;
|
|
653
|
+
border-top: 6px solid {t['text_secondary']};
|
|
654
|
+
margin-right: 8px;
|
|
655
|
+
}}
|
|
656
|
+
|
|
657
|
+
QComboBox QAbstractItemView {{
|
|
658
|
+
background-color: {t['surface_bg']};
|
|
659
|
+
color: {t['text']};
|
|
660
|
+
border: 1px solid {t['border']};
|
|
661
|
+
selection-background-color: {t['selection']};
|
|
662
|
+
}}
|
|
663
|
+
|
|
664
|
+
QTableWidget {{
|
|
665
|
+
background-color: {t['surface_bg']};
|
|
666
|
+
color: {t['text']};
|
|
667
|
+
border: 1px solid {t['border']};
|
|
668
|
+
border-radius: 6px;
|
|
669
|
+
gridline-color: {t['border']};
|
|
670
|
+
}}
|
|
671
|
+
|
|
672
|
+
QTableWidget QTableCornerButton::section {{
|
|
673
|
+
background-color: {t['table_header']};
|
|
674
|
+
border: none;
|
|
675
|
+
}}
|
|
676
|
+
|
|
677
|
+
QTableWidget QHeaderView {{
|
|
678
|
+
background-color: {t['table_header']};
|
|
679
|
+
}}
|
|
680
|
+
|
|
681
|
+
QTableView {{
|
|
682
|
+
background-color: {t['surface_bg']};
|
|
683
|
+
color: {t['text']};
|
|
684
|
+
gridline-color: {t['border']};
|
|
685
|
+
}}
|
|
686
|
+
|
|
687
|
+
QTableView::item {{
|
|
688
|
+
background-color: {t['surface_bg']};
|
|
689
|
+
color: {t['text']};
|
|
690
|
+
padding: 8px;
|
|
691
|
+
}}
|
|
692
|
+
|
|
693
|
+
QTableWidget::item {{
|
|
694
|
+
background-color: {t['surface_bg']};
|
|
695
|
+
color: {t['text']};
|
|
696
|
+
padding: 8px;
|
|
697
|
+
}}
|
|
698
|
+
|
|
699
|
+
QTableWidget::item:selected, QTableView::item:selected {{
|
|
700
|
+
background-color: {t['selection']};
|
|
701
|
+
}}
|
|
702
|
+
|
|
703
|
+
QTableWidget::item:alternate {{
|
|
704
|
+
background-color: {t['table_alt_row']};
|
|
705
|
+
}}
|
|
706
|
+
|
|
707
|
+
QHeaderView {{
|
|
708
|
+
background-color: {t['table_header']};
|
|
709
|
+
}}
|
|
710
|
+
|
|
711
|
+
QHeaderView::section {{
|
|
712
|
+
background-color: {t['table_header']};
|
|
713
|
+
color: {t['text']};
|
|
714
|
+
border: none;
|
|
715
|
+
border-bottom: 1px solid {t['border']};
|
|
716
|
+
border-right: 1px solid {t['border']};
|
|
717
|
+
padding: 10px 8px;
|
|
718
|
+
font-weight: 600;
|
|
719
|
+
}}
|
|
720
|
+
|
|
721
|
+
QCheckBox {{
|
|
722
|
+
spacing: 8px;
|
|
723
|
+
}}
|
|
724
|
+
|
|
725
|
+
QCheckBox::indicator {{
|
|
726
|
+
width: 18px;
|
|
727
|
+
height: 18px;
|
|
728
|
+
border: 2px solid {t['input_border']};
|
|
729
|
+
border-radius: 4px;
|
|
730
|
+
background-color: {t['input_bg']};
|
|
731
|
+
}}
|
|
732
|
+
|
|
733
|
+
QCheckBox::indicator:checked {{
|
|
734
|
+
background-color: {t['primary']};
|
|
735
|
+
border-color: {t['primary']};
|
|
736
|
+
}}
|
|
737
|
+
|
|
738
|
+
QLabel {{
|
|
739
|
+
color: {t['text']};
|
|
740
|
+
}}
|
|
741
|
+
|
|
742
|
+
QLabel[heading="true"] {{
|
|
743
|
+
font-size: 16px;
|
|
744
|
+
font-weight: 600;
|
|
745
|
+
color: {t['text']};
|
|
746
|
+
}}
|
|
747
|
+
|
|
748
|
+
QLabel[subheading="true"] {{
|
|
749
|
+
color: {t['text_secondary']};
|
|
750
|
+
font-size: 12px;
|
|
751
|
+
}}
|
|
752
|
+
|
|
753
|
+
QSplitter::handle {{
|
|
754
|
+
background-color: {t['border']};
|
|
755
|
+
}}
|
|
756
|
+
|
|
757
|
+
QSplitter::handle:horizontal {{
|
|
758
|
+
width: 2px;
|
|
759
|
+
}}
|
|
760
|
+
|
|
761
|
+
QSplitter::handle:vertical {{
|
|
762
|
+
height: 2px;
|
|
763
|
+
}}
|
|
764
|
+
|
|
765
|
+
QScrollBar:vertical {{
|
|
766
|
+
background-color: {t['scrollbar_bg']};
|
|
767
|
+
width: 12px;
|
|
768
|
+
border-radius: 6px;
|
|
769
|
+
}}
|
|
770
|
+
|
|
771
|
+
QScrollBar::handle:vertical {{
|
|
772
|
+
background-color: {t['scrollbar_handle']};
|
|
773
|
+
min-height: 30px;
|
|
774
|
+
border-radius: 6px;
|
|
775
|
+
margin: 2px;
|
|
776
|
+
}}
|
|
777
|
+
|
|
778
|
+
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
|
|
779
|
+
height: 0px;
|
|
780
|
+
}}
|
|
781
|
+
|
|
782
|
+
QScrollBar:horizontal {{
|
|
783
|
+
background-color: {t['scrollbar_bg']};
|
|
784
|
+
height: 12px;
|
|
785
|
+
border-radius: 6px;
|
|
786
|
+
}}
|
|
787
|
+
|
|
788
|
+
QScrollBar::handle:horizontal {{
|
|
789
|
+
background-color: {t['scrollbar_handle']};
|
|
790
|
+
min-width: 30px;
|
|
791
|
+
border-radius: 6px;
|
|
792
|
+
margin: 2px;
|
|
793
|
+
}}
|
|
794
|
+
|
|
795
|
+
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
|
|
796
|
+
width: 0px;
|
|
797
|
+
}}
|
|
798
|
+
|
|
799
|
+
QStatusBar {{
|
|
800
|
+
background-color: {t['surface_alt']};
|
|
801
|
+
color: {t['text_secondary']};
|
|
802
|
+
border-top: 1px solid {t['border']};
|
|
803
|
+
}}
|
|
804
|
+
|
|
805
|
+
QMenu {{
|
|
806
|
+
background-color: {t['surface_bg']};
|
|
807
|
+
color: {t['text']};
|
|
808
|
+
border: 1px solid {t['border']};
|
|
809
|
+
border-radius: 6px;
|
|
810
|
+
padding: 4px;
|
|
811
|
+
}}
|
|
812
|
+
|
|
813
|
+
QMenu::item {{
|
|
814
|
+
padding: 8px 24px;
|
|
815
|
+
border-radius: 4px;
|
|
816
|
+
}}
|
|
817
|
+
|
|
818
|
+
QMenu::item:selected {{
|
|
819
|
+
background-color: {t['selection']};
|
|
820
|
+
}}
|
|
821
|
+
|
|
822
|
+
QToolBar {{
|
|
823
|
+
background-color: {t['surface_alt']};
|
|
824
|
+
border: none;
|
|
825
|
+
border-bottom: 1px solid {t['border']};
|
|
826
|
+
padding: 4px;
|
|
827
|
+
spacing: 4px;
|
|
828
|
+
}}
|
|
829
|
+
|
|
830
|
+
QFrame[frameShape="4"] {{
|
|
831
|
+
background-color: {t['border']};
|
|
832
|
+
max-height: 1px;
|
|
833
|
+
}}
|
|
834
|
+
"""
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
# =============================================================================
|
|
838
|
+
# WORKER THREADS
|
|
839
|
+
# =============================================================================
|
|
840
|
+
|
|
841
|
+
class TemplateTestWorker(QThread):
|
|
842
|
+
"""Worker thread for database template testing"""
|
|
843
|
+
# Signal: best_template, best_parsed, best_score, all_scores, template_content
|
|
844
|
+
results_ready = pyqtSignal(str, list, float, list, str)
|
|
845
|
+
error_occurred = pyqtSignal(str)
|
|
846
|
+
|
|
847
|
+
def __init__(self, db_path: str, device_output: str, filter_string: str, verbose: bool = True):
|
|
848
|
+
super().__init__()
|
|
849
|
+
self.db_path = db_path
|
|
850
|
+
self.device_output = device_output
|
|
851
|
+
self.filter_string = filter_string
|
|
852
|
+
self.verbose = verbose
|
|
853
|
+
|
|
854
|
+
def run(self):
|
|
855
|
+
if not TFSM_ENGINE_AVAILABLE:
|
|
856
|
+
self.error_occurred.emit("TextFSM Engine not available. Use Manual Test tab instead.")
|
|
857
|
+
return
|
|
858
|
+
|
|
859
|
+
try:
|
|
860
|
+
engine = TextFSMAutoEngine(self.db_path, verbose=self.verbose)
|
|
861
|
+
|
|
862
|
+
# find_best_template returns: (best_template, best_parsed, best_score, all_scores)
|
|
863
|
+
# all_scores is List[Tuple[str, float, int]] - (template_name, score, record_count)
|
|
864
|
+
result = engine.find_best_template(self.device_output, self.filter_string)
|
|
865
|
+
|
|
866
|
+
best_template, best_parsed, best_score, all_scores = result
|
|
867
|
+
|
|
868
|
+
# Fetch template content from database
|
|
869
|
+
template_content = None
|
|
870
|
+
if best_template:
|
|
871
|
+
with engine.connection_manager.get_connection() as conn:
|
|
872
|
+
cursor = conn.cursor()
|
|
873
|
+
cursor.execute(
|
|
874
|
+
"SELECT textfsm_content FROM templates WHERE cli_command = ?",
|
|
875
|
+
(best_template,)
|
|
876
|
+
)
|
|
877
|
+
row = cursor.fetchone()
|
|
878
|
+
if row:
|
|
879
|
+
template_content = row['textfsm_content'] if isinstance(row, dict) else row[0]
|
|
880
|
+
|
|
881
|
+
self.results_ready.emit(
|
|
882
|
+
best_template or "None",
|
|
883
|
+
best_parsed or [],
|
|
884
|
+
best_score,
|
|
885
|
+
all_scores or [], # List of (template_name, score, record_count) tuples
|
|
886
|
+
template_content or ""
|
|
887
|
+
)
|
|
888
|
+
except Exception as e:
|
|
889
|
+
traceback.print_exc()
|
|
890
|
+
self.error_occurred.emit(str(e))
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
class ManualTestWorker(QThread):
|
|
894
|
+
"""Worker thread for manual template testing"""
|
|
895
|
+
results_ready = pyqtSignal(list, list, str) # headers, data, error
|
|
896
|
+
|
|
897
|
+
def __init__(self, template_content: str, device_output: str):
|
|
898
|
+
super().__init__()
|
|
899
|
+
self.template_content = template_content
|
|
900
|
+
self.device_output = device_output
|
|
901
|
+
|
|
902
|
+
def run(self):
|
|
903
|
+
try:
|
|
904
|
+
template = textfsm.TextFSM(io.StringIO(self.template_content))
|
|
905
|
+
parsed = template.ParseText(self.device_output)
|
|
906
|
+
headers = template.header
|
|
907
|
+
self.results_ready.emit(headers, parsed, "")
|
|
908
|
+
except Exception as e:
|
|
909
|
+
traceback.print_exc()
|
|
910
|
+
self.results_ready.emit([], [], str(e))
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
# =============================================================================
|
|
914
|
+
# TEMPLATE EDITOR DIALOG
|
|
915
|
+
# =============================================================================
|
|
916
|
+
|
|
917
|
+
class TemplateEditorDialog(QDialog):
|
|
918
|
+
"""Dialog for creating/editing templates"""
|
|
919
|
+
|
|
920
|
+
def __init__(self, parent=None, template_data: Optional[Dict] = None):
|
|
921
|
+
super().__init__(parent)
|
|
922
|
+
self.template_data = template_data
|
|
923
|
+
self.setWindowTitle("Edit Template" if template_data else "New Template")
|
|
924
|
+
self.setMinimumSize(800, 600)
|
|
925
|
+
self.init_ui()
|
|
926
|
+
|
|
927
|
+
if template_data:
|
|
928
|
+
self.load_template(template_data)
|
|
929
|
+
|
|
930
|
+
def init_ui(self):
|
|
931
|
+
layout = QVBoxLayout(self)
|
|
932
|
+
layout.setSpacing(16)
|
|
933
|
+
|
|
934
|
+
# Form fields
|
|
935
|
+
form_layout = QFormLayout()
|
|
936
|
+
form_layout.setSpacing(12)
|
|
937
|
+
|
|
938
|
+
self.cli_command_input = QLineEdit()
|
|
939
|
+
self.cli_command_input.setPlaceholderText("e.g., cisco_ios_show_ip_arp")
|
|
940
|
+
form_layout.addRow("CLI Command:", self.cli_command_input)
|
|
941
|
+
|
|
942
|
+
self.source_input = QLineEdit()
|
|
943
|
+
self.source_input.setPlaceholderText("e.g., ntc-templates, custom")
|
|
944
|
+
form_layout.addRow("Source:", self.source_input)
|
|
945
|
+
|
|
946
|
+
layout.addLayout(form_layout)
|
|
947
|
+
|
|
948
|
+
# Template content
|
|
949
|
+
content_label = QLabel("TextFSM Template Content:")
|
|
950
|
+
content_label.setProperty("heading", True)
|
|
951
|
+
layout.addWidget(content_label)
|
|
952
|
+
|
|
953
|
+
self.textfsm_content = QTextEdit()
|
|
954
|
+
self.textfsm_content.setPlaceholderText("""Value IP_ADDRESS (\\d+\\.\\d+\\.\\d+\\.\\d+)
|
|
955
|
+
Value MAC_ADDRESS ([a-fA-F0-9:.-]+)
|
|
956
|
+
Value INTERFACE (\\S+)
|
|
957
|
+
|
|
958
|
+
Start
|
|
959
|
+
^${IP_ADDRESS}\\s+${MAC_ADDRESS}\\s+${INTERFACE} -> Record
|
|
960
|
+
|
|
961
|
+
End""")
|
|
962
|
+
self.textfsm_content.setMinimumHeight(300)
|
|
963
|
+
layout.addWidget(self.textfsm_content)
|
|
964
|
+
|
|
965
|
+
# CLI content (optional)
|
|
966
|
+
cli_label = QLabel("CLI Content (optional):")
|
|
967
|
+
layout.addWidget(cli_label)
|
|
968
|
+
|
|
969
|
+
self.cli_content = QTextEdit()
|
|
970
|
+
self.cli_content.setMaximumHeight(100)
|
|
971
|
+
self.cli_content.setPlaceholderText("Original CLI command documentation or notes...")
|
|
972
|
+
layout.addWidget(self.cli_content)
|
|
973
|
+
|
|
974
|
+
# Buttons
|
|
975
|
+
button_box = QDialogButtonBox(
|
|
976
|
+
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel
|
|
977
|
+
)
|
|
978
|
+
button_box.accepted.connect(self.accept)
|
|
979
|
+
button_box.rejected.connect(self.reject)
|
|
980
|
+
layout.addWidget(button_box)
|
|
981
|
+
|
|
982
|
+
def load_template(self, data: Dict):
|
|
983
|
+
self.cli_command_input.setText(data.get('cli_command', ''))
|
|
984
|
+
self.source_input.setText(data.get('source', ''))
|
|
985
|
+
self.textfsm_content.setPlainText(data.get('textfsm_content', ''))
|
|
986
|
+
self.cli_content.setPlainText(data.get('cli_content', ''))
|
|
987
|
+
|
|
988
|
+
def get_template_data(self) -> Dict:
|
|
989
|
+
content = self.textfsm_content.toPlainText()
|
|
990
|
+
return {
|
|
991
|
+
'cli_command': self.cli_command_input.text().strip(),
|
|
992
|
+
'source': self.source_input.text().strip() or 'custom',
|
|
993
|
+
'textfsm_content': content,
|
|
994
|
+
'textfsm_hash': hashlib.md5(content.encode()).hexdigest(),
|
|
995
|
+
'cli_content': self.cli_content.toPlainText().strip(),
|
|
996
|
+
'created': datetime.now().isoformat()
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
def validate(self) -> tuple:
|
|
1000
|
+
if not self.cli_command_input.text().strip():
|
|
1001
|
+
return False, "CLI Command is required"
|
|
1002
|
+
if not self.textfsm_content.toPlainText().strip():
|
|
1003
|
+
return False, "TextFSM content is required"
|
|
1004
|
+
|
|
1005
|
+
# Try to parse the template
|
|
1006
|
+
try:
|
|
1007
|
+
textfsm.TextFSM(io.StringIO(self.textfsm_content.toPlainText()))
|
|
1008
|
+
except Exception as e:
|
|
1009
|
+
return False, f"Invalid TextFSM template: {str(e)}"
|
|
1010
|
+
|
|
1011
|
+
return True, ""
|
|
1012
|
+
|
|
1013
|
+
def accept(self):
|
|
1014
|
+
valid, error = self.validate()
|
|
1015
|
+
if not valid:
|
|
1016
|
+
QMessageBox.warning(self, "Validation Error", error)
|
|
1017
|
+
return
|
|
1018
|
+
super().accept()
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
# =============================================================================
|
|
1022
|
+
# MAIN APPLICATION
|
|
1023
|
+
# =============================================================================
|
|
1024
|
+
|
|
1025
|
+
class TextFSMTester(QMainWindow):
|
|
1026
|
+
def __init__(self):
|
|
1027
|
+
super().__init__()
|
|
1028
|
+
self.setWindowTitle("TextFSM Template Tester")
|
|
1029
|
+
self.setGeometry(100, 100, 1400, 900)
|
|
1030
|
+
|
|
1031
|
+
# Settings
|
|
1032
|
+
db = find_database()
|
|
1033
|
+
self.db_path = str(db) if db else str(get_package_db_path())
|
|
1034
|
+
self.current_theme = "dark"
|
|
1035
|
+
|
|
1036
|
+
self.init_ui()
|
|
1037
|
+
self.apply_theme(self.current_theme)
|
|
1038
|
+
|
|
1039
|
+
def init_ui(self):
|
|
1040
|
+
# Central widget
|
|
1041
|
+
central_widget = QWidget()
|
|
1042
|
+
self.setCentralWidget(central_widget)
|
|
1043
|
+
layout = QVBoxLayout(central_widget)
|
|
1044
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
1045
|
+
layout.setSpacing(0)
|
|
1046
|
+
|
|
1047
|
+
# Toolbar
|
|
1048
|
+
self.create_toolbar()
|
|
1049
|
+
|
|
1050
|
+
# Main content
|
|
1051
|
+
content_widget = QWidget()
|
|
1052
|
+
content_layout = QVBoxLayout(content_widget)
|
|
1053
|
+
content_layout.setContentsMargins(16, 16, 16, 16)
|
|
1054
|
+
|
|
1055
|
+
# Main tabs
|
|
1056
|
+
self.main_tabs = QTabWidget()
|
|
1057
|
+
|
|
1058
|
+
# Tab 1: Database Testing
|
|
1059
|
+
self.main_tabs.addTab(self.create_db_test_tab(), "Database Test")
|
|
1060
|
+
|
|
1061
|
+
# Tab 2: Manual Testing
|
|
1062
|
+
self.main_tabs.addTab(self.create_manual_test_tab(), "Manual Test")
|
|
1063
|
+
|
|
1064
|
+
# Tab 3: Template Manager (CRUD)
|
|
1065
|
+
self.main_tabs.addTab(self.create_template_manager_tab(), "Template Manager")
|
|
1066
|
+
|
|
1067
|
+
content_layout.addWidget(self.main_tabs)
|
|
1068
|
+
layout.addWidget(content_widget)
|
|
1069
|
+
|
|
1070
|
+
# Status bar
|
|
1071
|
+
self.statusBar().showMessage("Ready")
|
|
1072
|
+
|
|
1073
|
+
def create_toolbar(self):
|
|
1074
|
+
toolbar = QToolBar()
|
|
1075
|
+
toolbar.setMovable(False)
|
|
1076
|
+
toolbar.setIconSize(QSize(20, 20))
|
|
1077
|
+
self.addToolBar(toolbar)
|
|
1078
|
+
|
|
1079
|
+
# Theme selector
|
|
1080
|
+
theme_label = QLabel(" Theme: ")
|
|
1081
|
+
toolbar.addWidget(theme_label)
|
|
1082
|
+
|
|
1083
|
+
self.theme_combo = QComboBox()
|
|
1084
|
+
self.theme_combo.addItems(["Light", "Dark", "Cyber"])
|
|
1085
|
+
self.theme_combo.setCurrentText(self.current_theme.capitalize())
|
|
1086
|
+
self.theme_combo.currentTextChanged.connect(lambda t: self.apply_theme(t.lower()))
|
|
1087
|
+
toolbar.addWidget(self.theme_combo)
|
|
1088
|
+
|
|
1089
|
+
toolbar.addSeparator()
|
|
1090
|
+
|
|
1091
|
+
# Database selector
|
|
1092
|
+
db_label = QLabel(" Database: ")
|
|
1093
|
+
toolbar.addWidget(db_label)
|
|
1094
|
+
|
|
1095
|
+
self.db_path_input = QLineEdit(self.db_path)
|
|
1096
|
+
self.db_path_input.setMinimumWidth(300)
|
|
1097
|
+
toolbar.addWidget(self.db_path_input)
|
|
1098
|
+
|
|
1099
|
+
browse_btn = QPushButton("Browse")
|
|
1100
|
+
browse_btn.setProperty("secondary", True)
|
|
1101
|
+
browse_btn.clicked.connect(self.browse_database)
|
|
1102
|
+
toolbar.addWidget(browse_btn)
|
|
1103
|
+
|
|
1104
|
+
toolbar.addSeparator()
|
|
1105
|
+
|
|
1106
|
+
# Quick actions
|
|
1107
|
+
new_db_btn = QPushButton("New DB")
|
|
1108
|
+
new_db_btn.setProperty("secondary", True)
|
|
1109
|
+
new_db_btn.clicked.connect(self.create_new_database)
|
|
1110
|
+
toolbar.addWidget(new_db_btn)
|
|
1111
|
+
|
|
1112
|
+
def create_db_test_tab(self) -> QWidget:
|
|
1113
|
+
"""Create the database testing tab"""
|
|
1114
|
+
widget = QWidget()
|
|
1115
|
+
layout = QVBoxLayout(widget)
|
|
1116
|
+
|
|
1117
|
+
# Controls
|
|
1118
|
+
controls_group = QGroupBox("Test Controls")
|
|
1119
|
+
controls_layout = QVBoxLayout(controls_group)
|
|
1120
|
+
|
|
1121
|
+
filter_layout = QHBoxLayout()
|
|
1122
|
+
filter_layout.addWidget(QLabel("Filter String:"))
|
|
1123
|
+
self.filter_input = QLineEdit("show_lldp_neighbor")
|
|
1124
|
+
self.filter_input.setPlaceholderText("e.g., show_lldp_neighbor, show_cdp_neighbor, show_ip_arp")
|
|
1125
|
+
filter_layout.addWidget(self.filter_input)
|
|
1126
|
+
controls_layout.addLayout(filter_layout)
|
|
1127
|
+
|
|
1128
|
+
options_layout = QHBoxLayout()
|
|
1129
|
+
self.verbose_check = QCheckBox("Verbose Output")
|
|
1130
|
+
self.verbose_check.setChecked(True)
|
|
1131
|
+
options_layout.addWidget(self.verbose_check)
|
|
1132
|
+
options_layout.addStretch()
|
|
1133
|
+
|
|
1134
|
+
self.db_test_btn = QPushButton("Test Against Database")
|
|
1135
|
+
self.db_test_btn.clicked.connect(self.test_db_templates)
|
|
1136
|
+
options_layout.addWidget(self.db_test_btn)
|
|
1137
|
+
controls_layout.addLayout(options_layout)
|
|
1138
|
+
|
|
1139
|
+
layout.addWidget(controls_group)
|
|
1140
|
+
|
|
1141
|
+
# Splitter for input/output
|
|
1142
|
+
splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
1143
|
+
|
|
1144
|
+
# Input
|
|
1145
|
+
input_group = QGroupBox("Device Output")
|
|
1146
|
+
input_layout = QVBoxLayout(input_group)
|
|
1147
|
+
|
|
1148
|
+
sample_btn = QPushButton("Load Sample LLDP")
|
|
1149
|
+
sample_btn.setProperty("secondary", True)
|
|
1150
|
+
sample_btn.clicked.connect(self.load_sample_output)
|
|
1151
|
+
input_layout.addWidget(sample_btn)
|
|
1152
|
+
|
|
1153
|
+
self.db_input_text = QTextEdit()
|
|
1154
|
+
self.db_input_text.setPlaceholderText("Paste device output here...")
|
|
1155
|
+
input_layout.addWidget(self.db_input_text)
|
|
1156
|
+
splitter.addWidget(input_group)
|
|
1157
|
+
|
|
1158
|
+
# Results
|
|
1159
|
+
results_widget = QWidget()
|
|
1160
|
+
results_layout = QVBoxLayout(results_widget)
|
|
1161
|
+
results_layout.setContentsMargins(0, 0, 0, 0)
|
|
1162
|
+
|
|
1163
|
+
self.db_results_tabs = QTabWidget()
|
|
1164
|
+
|
|
1165
|
+
# Best results tab
|
|
1166
|
+
best_tab = QWidget()
|
|
1167
|
+
best_layout = QVBoxLayout(best_tab)
|
|
1168
|
+
|
|
1169
|
+
self.best_match_label = QLabel("Best Match: None")
|
|
1170
|
+
self.best_match_label.setProperty("heading", True)
|
|
1171
|
+
best_layout.addWidget(self.best_match_label)
|
|
1172
|
+
|
|
1173
|
+
self.score_label = QLabel("Score: 0.0")
|
|
1174
|
+
self.score_label.setProperty("subheading", True)
|
|
1175
|
+
best_layout.addWidget(self.score_label)
|
|
1176
|
+
|
|
1177
|
+
self.db_results_table = QTableWidget()
|
|
1178
|
+
self.db_results_table.setAlternatingRowColors(True)
|
|
1179
|
+
best_layout.addWidget(self.db_results_table)
|
|
1180
|
+
|
|
1181
|
+
# Export buttons for database test results
|
|
1182
|
+
db_export_layout = QHBoxLayout()
|
|
1183
|
+
export_db_json_btn = QPushButton("Export JSON")
|
|
1184
|
+
export_db_json_btn.setProperty("secondary", True)
|
|
1185
|
+
export_db_json_btn.clicked.connect(self.export_db_results_json)
|
|
1186
|
+
db_export_layout.addWidget(export_db_json_btn)
|
|
1187
|
+
|
|
1188
|
+
export_db_csv_btn = QPushButton("Export CSV")
|
|
1189
|
+
export_db_csv_btn.setProperty("secondary", True)
|
|
1190
|
+
export_db_csv_btn.clicked.connect(self.export_db_results_csv)
|
|
1191
|
+
db_export_layout.addWidget(export_db_csv_btn)
|
|
1192
|
+
|
|
1193
|
+
db_export_layout.addStretch()
|
|
1194
|
+
best_layout.addLayout(db_export_layout)
|
|
1195
|
+
|
|
1196
|
+
self.db_results_tabs.addTab(best_tab, "Best Results")
|
|
1197
|
+
|
|
1198
|
+
# All templates tab - NOW WITH SCORES
|
|
1199
|
+
all_tab = QWidget()
|
|
1200
|
+
all_layout = QVBoxLayout(all_tab)
|
|
1201
|
+
self.all_templates_table = QTableWidget()
|
|
1202
|
+
self.all_templates_table.setColumnCount(3)
|
|
1203
|
+
self.all_templates_table.setHorizontalHeaderLabels(["Template", "Score", "Records"])
|
|
1204
|
+
self.all_templates_table.setAlternatingRowColors(True)
|
|
1205
|
+
self.all_templates_table.setSortingEnabled(True)
|
|
1206
|
+
all_layout.addWidget(self.all_templates_table)
|
|
1207
|
+
self.db_results_tabs.addTab(all_tab, "All Scores")
|
|
1208
|
+
|
|
1209
|
+
# Log tab
|
|
1210
|
+
log_tab = QWidget()
|
|
1211
|
+
log_layout = QVBoxLayout(log_tab)
|
|
1212
|
+
self.db_log_text = QTextEdit()
|
|
1213
|
+
self.db_log_text.setReadOnly(True)
|
|
1214
|
+
log_layout.addWidget(self.db_log_text)
|
|
1215
|
+
self.db_results_tabs.addTab(log_tab, "Debug Log")
|
|
1216
|
+
|
|
1217
|
+
# Template Content tab (shows the winning template)
|
|
1218
|
+
template_tab = QWidget()
|
|
1219
|
+
template_tab_layout = QVBoxLayout(template_tab)
|
|
1220
|
+
|
|
1221
|
+
template_info_layout = QHBoxLayout()
|
|
1222
|
+
self.template_name_label = QLabel("No template matched yet")
|
|
1223
|
+
self.template_name_label.setProperty("heading", True)
|
|
1224
|
+
template_info_layout.addWidget(self.template_name_label)
|
|
1225
|
+
template_info_layout.addStretch()
|
|
1226
|
+
|
|
1227
|
+
copy_template_btn = QPushButton("Copy to Clipboard")
|
|
1228
|
+
copy_template_btn.setProperty("secondary", True)
|
|
1229
|
+
copy_template_btn.clicked.connect(self.copy_template_to_clipboard)
|
|
1230
|
+
template_info_layout.addWidget(copy_template_btn)
|
|
1231
|
+
|
|
1232
|
+
use_in_manual_btn = QPushButton("Open in Manual Test")
|
|
1233
|
+
use_in_manual_btn.setProperty("secondary", True)
|
|
1234
|
+
use_in_manual_btn.clicked.connect(self.use_template_in_manual)
|
|
1235
|
+
template_info_layout.addWidget(use_in_manual_btn)
|
|
1236
|
+
|
|
1237
|
+
template_tab_layout.addLayout(template_info_layout)
|
|
1238
|
+
|
|
1239
|
+
self.template_content_text = QTextEdit()
|
|
1240
|
+
self.template_content_text.setReadOnly(True)
|
|
1241
|
+
self.template_content_text.setPlaceholderText("The matched template content will appear here...")
|
|
1242
|
+
template_tab_layout.addWidget(self.template_content_text)
|
|
1243
|
+
|
|
1244
|
+
self.db_results_tabs.addTab(template_tab, "Template Content")
|
|
1245
|
+
|
|
1246
|
+
results_layout.addWidget(self.db_results_tabs)
|
|
1247
|
+
splitter.addWidget(results_widget)
|
|
1248
|
+
|
|
1249
|
+
splitter.setSizes([400, 600])
|
|
1250
|
+
layout.addWidget(splitter)
|
|
1251
|
+
|
|
1252
|
+
return widget
|
|
1253
|
+
|
|
1254
|
+
def create_manual_test_tab(self) -> QWidget:
|
|
1255
|
+
"""Create the manual testing tab (no database required)"""
|
|
1256
|
+
widget = QWidget()
|
|
1257
|
+
layout = QVBoxLayout(widget)
|
|
1258
|
+
|
|
1259
|
+
# Description
|
|
1260
|
+
desc_label = QLabel("Test TextFSM templates directly without database. Perfect for template development.")
|
|
1261
|
+
desc_label.setProperty("subheading", True)
|
|
1262
|
+
layout.addWidget(desc_label)
|
|
1263
|
+
|
|
1264
|
+
# Splitter
|
|
1265
|
+
splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
1266
|
+
|
|
1267
|
+
# Left side - inputs
|
|
1268
|
+
left_widget = QWidget()
|
|
1269
|
+
left_layout = QVBoxLayout(left_widget)
|
|
1270
|
+
left_layout.setContentsMargins(0, 0, 0, 0)
|
|
1271
|
+
|
|
1272
|
+
# Template input
|
|
1273
|
+
template_group = QGroupBox("TextFSM Template")
|
|
1274
|
+
template_layout = QVBoxLayout(template_group)
|
|
1275
|
+
|
|
1276
|
+
template_btn_layout = QHBoxLayout()
|
|
1277
|
+
load_template_btn = QPushButton("Load from File")
|
|
1278
|
+
load_template_btn.setProperty("secondary", True)
|
|
1279
|
+
load_template_btn.clicked.connect(self.load_template_file)
|
|
1280
|
+
template_btn_layout.addWidget(load_template_btn)
|
|
1281
|
+
|
|
1282
|
+
load_sample_template_btn = QPushButton("Load Sample")
|
|
1283
|
+
load_sample_template_btn.setProperty("secondary", True)
|
|
1284
|
+
load_sample_template_btn.clicked.connect(self.load_sample_template)
|
|
1285
|
+
template_btn_layout.addWidget(load_sample_template_btn)
|
|
1286
|
+
template_btn_layout.addStretch()
|
|
1287
|
+
template_layout.addLayout(template_btn_layout)
|
|
1288
|
+
|
|
1289
|
+
self.manual_template_text = QTextEdit()
|
|
1290
|
+
self.manual_template_text.setPlaceholderText("""Value NEIGHBOR (\\S+)
|
|
1291
|
+
Value LOCAL_INTERFACE (\\S+)
|
|
1292
|
+
Value NEIGHBOR_INTERFACE (\\S+)
|
|
1293
|
+
|
|
1294
|
+
Start
|
|
1295
|
+
^${NEIGHBOR}\\s+${LOCAL_INTERFACE}\\s+\\d+\\s+\\S+\\s+${NEIGHBOR_INTERFACE} -> Record""")
|
|
1296
|
+
template_layout.addWidget(self.manual_template_text)
|
|
1297
|
+
left_layout.addWidget(template_group)
|
|
1298
|
+
|
|
1299
|
+
# Device output input
|
|
1300
|
+
output_group = QGroupBox("Device Output")
|
|
1301
|
+
output_layout = QVBoxLayout(output_group)
|
|
1302
|
+
|
|
1303
|
+
output_btn_layout = QHBoxLayout()
|
|
1304
|
+
load_output_btn = QPushButton("Load from File")
|
|
1305
|
+
load_output_btn.setProperty("secondary", True)
|
|
1306
|
+
load_output_btn.clicked.connect(self.load_output_file)
|
|
1307
|
+
output_btn_layout.addWidget(load_output_btn)
|
|
1308
|
+
|
|
1309
|
+
load_sample_output_btn = QPushButton("Load Sample")
|
|
1310
|
+
load_sample_output_btn.setProperty("secondary", True)
|
|
1311
|
+
load_sample_output_btn.clicked.connect(self.load_sample_manual_output)
|
|
1312
|
+
output_btn_layout.addWidget(load_sample_output_btn)
|
|
1313
|
+
output_btn_layout.addStretch()
|
|
1314
|
+
output_layout.addLayout(output_btn_layout)
|
|
1315
|
+
|
|
1316
|
+
self.manual_output_text = QTextEdit()
|
|
1317
|
+
self.manual_output_text.setPlaceholderText("Paste device output here...")
|
|
1318
|
+
output_layout.addWidget(self.manual_output_text)
|
|
1319
|
+
left_layout.addWidget(output_group)
|
|
1320
|
+
|
|
1321
|
+
splitter.addWidget(left_widget)
|
|
1322
|
+
|
|
1323
|
+
# Right side - results
|
|
1324
|
+
right_widget = QWidget()
|
|
1325
|
+
right_layout = QVBoxLayout(right_widget)
|
|
1326
|
+
right_layout.setContentsMargins(0, 0, 0, 0)
|
|
1327
|
+
|
|
1328
|
+
results_group = QGroupBox("Parse Results")
|
|
1329
|
+
results_inner_layout = QVBoxLayout(results_group)
|
|
1330
|
+
|
|
1331
|
+
self.manual_test_btn = QPushButton("Parse Template")
|
|
1332
|
+
self.manual_test_btn.clicked.connect(self.test_manual_template)
|
|
1333
|
+
results_inner_layout.addWidget(self.manual_test_btn)
|
|
1334
|
+
|
|
1335
|
+
self.manual_status_label = QLabel("")
|
|
1336
|
+
self.manual_status_label.setProperty("subheading", True)
|
|
1337
|
+
results_inner_layout.addWidget(self.manual_status_label)
|
|
1338
|
+
|
|
1339
|
+
self.manual_results_table = QTableWidget()
|
|
1340
|
+
self.manual_results_table.setAlternatingRowColors(True)
|
|
1341
|
+
results_inner_layout.addWidget(self.manual_results_table)
|
|
1342
|
+
|
|
1343
|
+
# Export buttons
|
|
1344
|
+
export_layout = QHBoxLayout()
|
|
1345
|
+
export_json_btn = QPushButton("Export JSON")
|
|
1346
|
+
export_json_btn.setProperty("secondary", True)
|
|
1347
|
+
export_json_btn.clicked.connect(self.export_manual_results_json)
|
|
1348
|
+
export_layout.addWidget(export_json_btn)
|
|
1349
|
+
|
|
1350
|
+
export_csv_btn = QPushButton("Export CSV")
|
|
1351
|
+
export_csv_btn.setProperty("secondary", True)
|
|
1352
|
+
export_csv_btn.clicked.connect(self.export_manual_results_csv)
|
|
1353
|
+
export_layout.addWidget(export_csv_btn)
|
|
1354
|
+
|
|
1355
|
+
save_template_btn = QPushButton("Save to Database")
|
|
1356
|
+
save_template_btn.clicked.connect(self.save_manual_template_to_db)
|
|
1357
|
+
export_layout.addWidget(save_template_btn)
|
|
1358
|
+
|
|
1359
|
+
export_layout.addStretch()
|
|
1360
|
+
results_inner_layout.addLayout(export_layout)
|
|
1361
|
+
|
|
1362
|
+
right_layout.addWidget(results_group)
|
|
1363
|
+
splitter.addWidget(right_widget)
|
|
1364
|
+
|
|
1365
|
+
splitter.setSizes([500, 500])
|
|
1366
|
+
layout.addWidget(splitter)
|
|
1367
|
+
|
|
1368
|
+
return widget
|
|
1369
|
+
|
|
1370
|
+
def create_template_manager_tab(self) -> QWidget:
|
|
1371
|
+
"""Create the template manager (CRUD) tab"""
|
|
1372
|
+
widget = QWidget()
|
|
1373
|
+
layout = QVBoxLayout(widget)
|
|
1374
|
+
|
|
1375
|
+
# Search/filter bar
|
|
1376
|
+
filter_group = QGroupBox("Search Templates")
|
|
1377
|
+
filter_layout = QHBoxLayout(filter_group)
|
|
1378
|
+
|
|
1379
|
+
filter_layout.addWidget(QLabel("Search:"))
|
|
1380
|
+
self.mgr_search_input = QLineEdit()
|
|
1381
|
+
self.mgr_search_input.setPlaceholderText("Filter by command name...")
|
|
1382
|
+
self.mgr_search_input.textChanged.connect(self.filter_templates)
|
|
1383
|
+
filter_layout.addWidget(self.mgr_search_input)
|
|
1384
|
+
|
|
1385
|
+
refresh_btn = QPushButton("Refresh")
|
|
1386
|
+
refresh_btn.setProperty("secondary", True)
|
|
1387
|
+
refresh_btn.clicked.connect(self.load_all_templates)
|
|
1388
|
+
filter_layout.addWidget(refresh_btn)
|
|
1389
|
+
|
|
1390
|
+
layout.addWidget(filter_group)
|
|
1391
|
+
|
|
1392
|
+
# Template table
|
|
1393
|
+
self.mgr_table = QTableWidget()
|
|
1394
|
+
self.mgr_table.setColumnCount(5)
|
|
1395
|
+
self.mgr_table.setHorizontalHeaderLabels(["ID", "CLI Command", "Source", "Created", "Hash"])
|
|
1396
|
+
self.mgr_table.setAlternatingRowColors(True)
|
|
1397
|
+
self.mgr_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
1398
|
+
self.mgr_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
|
1399
|
+
self.mgr_table.horizontalHeader().setStretchLastSection(True)
|
|
1400
|
+
self.mgr_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
|
1401
|
+
self.mgr_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
1402
|
+
self.mgr_table.customContextMenuRequested.connect(self.show_template_context_menu)
|
|
1403
|
+
self.mgr_table.doubleClicked.connect(self.edit_selected_template)
|
|
1404
|
+
layout.addWidget(self.mgr_table)
|
|
1405
|
+
|
|
1406
|
+
# Action buttons
|
|
1407
|
+
btn_layout = QHBoxLayout()
|
|
1408
|
+
|
|
1409
|
+
add_btn = QPushButton("Add Template")
|
|
1410
|
+
add_btn.clicked.connect(self.add_template)
|
|
1411
|
+
btn_layout.addWidget(add_btn)
|
|
1412
|
+
|
|
1413
|
+
edit_btn = QPushButton("Edit Selected")
|
|
1414
|
+
edit_btn.setProperty("secondary", True)
|
|
1415
|
+
edit_btn.clicked.connect(self.edit_selected_template)
|
|
1416
|
+
btn_layout.addWidget(edit_btn)
|
|
1417
|
+
|
|
1418
|
+
delete_btn = QPushButton("Delete Selected")
|
|
1419
|
+
delete_btn.setProperty("danger", True)
|
|
1420
|
+
delete_btn.clicked.connect(self.delete_selected_template)
|
|
1421
|
+
btn_layout.addWidget(delete_btn)
|
|
1422
|
+
|
|
1423
|
+
btn_layout.addStretch()
|
|
1424
|
+
|
|
1425
|
+
import_btn = QPushButton("Import from NTC")
|
|
1426
|
+
import_btn.setProperty("secondary", True)
|
|
1427
|
+
import_btn.clicked.connect(self.import_from_ntc)
|
|
1428
|
+
btn_layout.addWidget(import_btn)
|
|
1429
|
+
|
|
1430
|
+
download_btn = QPushButton("Download from NTC")
|
|
1431
|
+
download_btn.setProperty("secondary", True)
|
|
1432
|
+
download_btn.clicked.connect(self.download_from_ntc)
|
|
1433
|
+
btn_layout.addWidget(download_btn)
|
|
1434
|
+
|
|
1435
|
+
export_btn = QPushButton("Export All")
|
|
1436
|
+
export_btn.setProperty("secondary", True)
|
|
1437
|
+
export_btn.clicked.connect(self.export_all_templates)
|
|
1438
|
+
btn_layout.addWidget(export_btn)
|
|
1439
|
+
|
|
1440
|
+
layout.addLayout(btn_layout)
|
|
1441
|
+
|
|
1442
|
+
# Template preview
|
|
1443
|
+
preview_group = QGroupBox("Template Preview")
|
|
1444
|
+
preview_layout = QVBoxLayout(preview_group)
|
|
1445
|
+
|
|
1446
|
+
self.mgr_preview_text = QTextEdit()
|
|
1447
|
+
self.mgr_preview_text.setReadOnly(True)
|
|
1448
|
+
self.mgr_preview_text.setMaximumHeight(200)
|
|
1449
|
+
preview_layout.addWidget(self.mgr_preview_text)
|
|
1450
|
+
|
|
1451
|
+
layout.addWidget(preview_group)
|
|
1452
|
+
|
|
1453
|
+
# Connect selection change to preview
|
|
1454
|
+
self.mgr_table.selectionModel().selectionChanged.connect(self.update_template_preview)
|
|
1455
|
+
|
|
1456
|
+
return widget
|
|
1457
|
+
|
|
1458
|
+
# =========================================================================
|
|
1459
|
+
# THEME HANDLING
|
|
1460
|
+
# =========================================================================
|
|
1461
|
+
|
|
1462
|
+
def apply_theme(self, theme_name: str):
|
|
1463
|
+
self.current_theme = theme_name
|
|
1464
|
+
self.setStyleSheet(get_stylesheet(theme_name))
|
|
1465
|
+
|
|
1466
|
+
# =========================================================================
|
|
1467
|
+
# DATABASE OPERATIONS
|
|
1468
|
+
# =========================================================================
|
|
1469
|
+
|
|
1470
|
+
def browse_database(self):
|
|
1471
|
+
file_path, _ = QFileDialog.getOpenFileName(
|
|
1472
|
+
self, "Select TextFSM Database", "", "Database Files (*.db);;All Files (*)"
|
|
1473
|
+
)
|
|
1474
|
+
if file_path:
|
|
1475
|
+
self.db_path_input.setText(file_path)
|
|
1476
|
+
self.db_path = file_path
|
|
1477
|
+
self.load_all_templates()
|
|
1478
|
+
|
|
1479
|
+
def create_new_database(self):
|
|
1480
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
1481
|
+
self, "Create New Database", "tfsm_templates.db", "Database Files (*.db)"
|
|
1482
|
+
)
|
|
1483
|
+
if file_path:
|
|
1484
|
+
try:
|
|
1485
|
+
conn = sqlite3.connect(file_path)
|
|
1486
|
+
conn.execute("""
|
|
1487
|
+
CREATE TABLE IF NOT EXISTS templates (
|
|
1488
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1489
|
+
cli_command TEXT NOT NULL,
|
|
1490
|
+
cli_content TEXT,
|
|
1491
|
+
textfsm_content TEXT NOT NULL,
|
|
1492
|
+
textfsm_hash TEXT,
|
|
1493
|
+
source TEXT,
|
|
1494
|
+
created TEXT
|
|
1495
|
+
)
|
|
1496
|
+
""")
|
|
1497
|
+
conn.commit()
|
|
1498
|
+
conn.close()
|
|
1499
|
+
|
|
1500
|
+
self.db_path = file_path
|
|
1501
|
+
self.db_path_input.setText(file_path)
|
|
1502
|
+
self.statusBar().showMessage(f"Created new database: {file_path}")
|
|
1503
|
+
QMessageBox.information(self, "Success", f"Created new database: {file_path}")
|
|
1504
|
+
except Exception as e:
|
|
1505
|
+
traceback.print_exc()
|
|
1506
|
+
QMessageBox.critical(self, "Error", f"Failed to create database: {str(e)}")
|
|
1507
|
+
|
|
1508
|
+
def get_db_connection(self) -> Optional[sqlite3.Connection]:
|
|
1509
|
+
"""Get database connection."""
|
|
1510
|
+
db_path = Path(self.db_path_input.text().strip())
|
|
1511
|
+
self.db_path = str(db_path)
|
|
1512
|
+
|
|
1513
|
+
if not db_path.exists():
|
|
1514
|
+
QMessageBox.warning(
|
|
1515
|
+
self, "Database Not Found",
|
|
1516
|
+
f"Database file not found:\n{db_path}\n\n"
|
|
1517
|
+
f"Use 'New DB' to create one or 'Browse' to locate an existing database."
|
|
1518
|
+
)
|
|
1519
|
+
return None
|
|
1520
|
+
|
|
1521
|
+
if db_path.is_dir():
|
|
1522
|
+
QMessageBox.warning(
|
|
1523
|
+
self, "Invalid Path",
|
|
1524
|
+
f"Path is a DIRECTORY, not a file:\n{db_path}\n\n"
|
|
1525
|
+
f"Please select the actual .db file, not a folder."
|
|
1526
|
+
)
|
|
1527
|
+
return None
|
|
1528
|
+
|
|
1529
|
+
try:
|
|
1530
|
+
conn = sqlite3.connect(str(db_path))
|
|
1531
|
+
conn.row_factory = sqlite3.Row
|
|
1532
|
+
return conn
|
|
1533
|
+
except Exception as e:
|
|
1534
|
+
traceback.print_exc()
|
|
1535
|
+
QMessageBox.critical(
|
|
1536
|
+
self, "Database Error",
|
|
1537
|
+
f"Failed to open database:\n{db_path}\n\nError: {e}"
|
|
1538
|
+
)
|
|
1539
|
+
return None
|
|
1540
|
+
|
|
1541
|
+
# =========================================================================
|
|
1542
|
+
# DATABASE TEST TAB
|
|
1543
|
+
# =========================================================================
|
|
1544
|
+
|
|
1545
|
+
def test_db_templates(self):
|
|
1546
|
+
device_output = self.db_input_text.toPlainText().strip()
|
|
1547
|
+
filter_string = self.filter_input.text().strip()
|
|
1548
|
+
|
|
1549
|
+
if not device_output:
|
|
1550
|
+
QMessageBox.warning(self, "Warning", "Please enter device output to test")
|
|
1551
|
+
return
|
|
1552
|
+
|
|
1553
|
+
if not Path(self.db_path_input.text()).exists():
|
|
1554
|
+
QMessageBox.critical(self, "Error", f"Database not found: {self.db_path_input.text()}")
|
|
1555
|
+
return
|
|
1556
|
+
|
|
1557
|
+
self.db_path = self.db_path_input.text()
|
|
1558
|
+
self.db_test_btn.setEnabled(False)
|
|
1559
|
+
self.statusBar().showMessage("Testing templates...")
|
|
1560
|
+
self.db_log_text.clear()
|
|
1561
|
+
|
|
1562
|
+
self.worker = TemplateTestWorker(
|
|
1563
|
+
self.db_path, device_output, filter_string, self.verbose_check.isChecked()
|
|
1564
|
+
)
|
|
1565
|
+
self.worker.results_ready.connect(self.handle_db_results)
|
|
1566
|
+
self.worker.error_occurred.connect(self.handle_db_error)
|
|
1567
|
+
self.worker.start()
|
|
1568
|
+
|
|
1569
|
+
def handle_db_results(self, best_template: str, best_parsed: list, best_score: float,
|
|
1570
|
+
all_scores: list, template_content: str):
|
|
1571
|
+
"""Handle results from TemplateTestWorker.
|
|
1572
|
+
|
|
1573
|
+
Args:
|
|
1574
|
+
best_template: Name of best matching template
|
|
1575
|
+
best_parsed: List of parsed dicts from best template
|
|
1576
|
+
best_score: Score of best template
|
|
1577
|
+
all_scores: List of (template_name, score, record_count) tuples
|
|
1578
|
+
template_content: TextFSM content of best template
|
|
1579
|
+
"""
|
|
1580
|
+
self.db_test_btn.setEnabled(True)
|
|
1581
|
+
self.statusBar().showMessage("Testing complete")
|
|
1582
|
+
|
|
1583
|
+
self.best_match_label.setText(f"Best Match: {best_template}")
|
|
1584
|
+
self.score_label.setText(f"Score: {best_score:.2f}")
|
|
1585
|
+
|
|
1586
|
+
# Store template content for later use
|
|
1587
|
+
self._current_template_content = template_content
|
|
1588
|
+
self._current_template_name = best_template
|
|
1589
|
+
|
|
1590
|
+
# Store parsed data for export
|
|
1591
|
+
self._db_parsed_data = best_parsed
|
|
1592
|
+
|
|
1593
|
+
# Update results table
|
|
1594
|
+
if best_parsed:
|
|
1595
|
+
self.db_results_table.setRowCount(len(best_parsed))
|
|
1596
|
+
self.db_results_table.setColumnCount(len(best_parsed[0]))
|
|
1597
|
+
self.db_results_table.setHorizontalHeaderLabels(list(best_parsed[0].keys()))
|
|
1598
|
+
|
|
1599
|
+
for row, item in enumerate(best_parsed):
|
|
1600
|
+
for col, (key, value) in enumerate(item.items()):
|
|
1601
|
+
self.db_results_table.setItem(row, col, QTableWidgetItem(str(value)))
|
|
1602
|
+
else:
|
|
1603
|
+
self.db_results_table.setRowCount(0)
|
|
1604
|
+
self.db_results_table.setColumnCount(0)
|
|
1605
|
+
|
|
1606
|
+
# Update all scores table - NOW PROPERLY USING all_scores
|
|
1607
|
+
self.all_templates_table.setSortingEnabled(False)
|
|
1608
|
+
self.all_templates_table.setRowCount(len(all_scores))
|
|
1609
|
+
|
|
1610
|
+
for row, (tmpl_name, score, record_count) in enumerate(all_scores):
|
|
1611
|
+
name_item = QTableWidgetItem(tmpl_name)
|
|
1612
|
+
score_item = QTableWidgetItem(f"{score:.2f}")
|
|
1613
|
+
records_item = QTableWidgetItem(str(record_count))
|
|
1614
|
+
|
|
1615
|
+
# Highlight best match
|
|
1616
|
+
if tmpl_name == best_template:
|
|
1617
|
+
for item in [name_item, score_item, records_item]:
|
|
1618
|
+
item.setBackground(QColor("#264F78"))
|
|
1619
|
+
|
|
1620
|
+
self.all_templates_table.setItem(row, 0, name_item)
|
|
1621
|
+
self.all_templates_table.setItem(row, 1, score_item)
|
|
1622
|
+
self.all_templates_table.setItem(row, 2, records_item)
|
|
1623
|
+
|
|
1624
|
+
self.all_templates_table.setSortingEnabled(True)
|
|
1625
|
+
self.all_templates_table.resizeColumnsToContents()
|
|
1626
|
+
|
|
1627
|
+
# Update template content tab
|
|
1628
|
+
self.template_name_label.setText(f"Template: {best_template}")
|
|
1629
|
+
if template_content:
|
|
1630
|
+
self.template_content_text.setPlainText(template_content)
|
|
1631
|
+
else:
|
|
1632
|
+
self.template_content_text.setPlainText("(Template content not available)")
|
|
1633
|
+
|
|
1634
|
+
# Log
|
|
1635
|
+
self.log_db_results(best_template, best_parsed, best_score, all_scores)
|
|
1636
|
+
self.db_results_tabs.setCurrentIndex(0)
|
|
1637
|
+
|
|
1638
|
+
def handle_db_error(self, error: str):
|
|
1639
|
+
self.db_test_btn.setEnabled(True)
|
|
1640
|
+
self.statusBar().showMessage("Error occurred")
|
|
1641
|
+
QMessageBox.critical(self, "Error", error)
|
|
1642
|
+
|
|
1643
|
+
def log_db_results(self, best_template: str, best_parsed: list, best_score: float, all_scores: list):
|
|
1644
|
+
log = []
|
|
1645
|
+
log.append("=" * 60)
|
|
1646
|
+
log.append("TEXTFSM TEMPLATE TEST RESULTS")
|
|
1647
|
+
log.append("=" * 60)
|
|
1648
|
+
log.append(f"Filter String: {self.filter_input.text()}")
|
|
1649
|
+
log.append(f"Templates Scored: {len(all_scores)}")
|
|
1650
|
+
log.append(f"Best Template: {best_template}")
|
|
1651
|
+
log.append(f"Best Score: {best_score:.2f}")
|
|
1652
|
+
log.append(f"Records Parsed: {len(best_parsed) if best_parsed else 0}")
|
|
1653
|
+
log.append("")
|
|
1654
|
+
|
|
1655
|
+
if best_parsed:
|
|
1656
|
+
log.append("PARSED DATA SAMPLE:")
|
|
1657
|
+
log.append("-" * 40)
|
|
1658
|
+
for i, record in enumerate(best_parsed[:3]):
|
|
1659
|
+
log.append(f"Record {i + 1}:")
|
|
1660
|
+
log.append(json.dumps(record, indent=2))
|
|
1661
|
+
log.append("")
|
|
1662
|
+
|
|
1663
|
+
if len(best_parsed) > 3:
|
|
1664
|
+
log.append(f"... and {len(best_parsed) - 3} more records")
|
|
1665
|
+
|
|
1666
|
+
log.append("")
|
|
1667
|
+
log.append("TOP 10 SCORING TEMPLATES:")
|
|
1668
|
+
log.append("-" * 40)
|
|
1669
|
+
for tmpl_name, score, records in all_scores[:10]:
|
|
1670
|
+
marker = " <-- BEST" if tmpl_name == best_template else ""
|
|
1671
|
+
log.append(f" {tmpl_name}: score={score:.2f}, records={records}{marker}")
|
|
1672
|
+
|
|
1673
|
+
self.db_log_text.setPlainText("\n".join(log))
|
|
1674
|
+
|
|
1675
|
+
def load_sample_output(self):
|
|
1676
|
+
sample = """usa-spine-2#show lldp neighbors detail
|
|
1677
|
+
Capability codes:
|
|
1678
|
+
(R) Router, (B) Bridge, (T) Telephone, (C) DOCSIS Cable Device
|
|
1679
|
+
(W) WLAN Access Point, (P) Repeater, (S) Station, (O) Other
|
|
1680
|
+
|
|
1681
|
+
Device ID Local Intf Hold-time Capability Port ID
|
|
1682
|
+
usa-spine-1 Eth2 120 B,R Ethernet2
|
|
1683
|
+
usa-rtr-1 Eth1 120 R GigabitEthernet0/2
|
|
1684
|
+
usa-leaf-3 Eth3 120 R GigabitEthernet0/0
|
|
1685
|
+
usa-leaf-2 Eth4 120 R GigabitEthernet0/0
|
|
1686
|
+
usa-leaf-1 Eth5 120 R GigabitEthernet0/0"""
|
|
1687
|
+
self.db_input_text.setPlainText(sample)
|
|
1688
|
+
self.filter_input.setText("show_lldp_neighbor")
|
|
1689
|
+
|
|
1690
|
+
def copy_template_to_clipboard(self):
|
|
1691
|
+
"""Copy the current template content to clipboard"""
|
|
1692
|
+
if hasattr(self, '_current_template_content') and self._current_template_content:
|
|
1693
|
+
clipboard = QApplication.clipboard()
|
|
1694
|
+
clipboard.setText(self._current_template_content)
|
|
1695
|
+
self.statusBar().showMessage("Template copied to clipboard")
|
|
1696
|
+
else:
|
|
1697
|
+
QMessageBox.warning(self, "Warning", "No template content to copy")
|
|
1698
|
+
|
|
1699
|
+
def use_template_in_manual(self):
|
|
1700
|
+
"""Load the current template into the Manual Test tab"""
|
|
1701
|
+
if hasattr(self, '_current_template_content') and self._current_template_content:
|
|
1702
|
+
self.manual_template_text.setPlainText(self._current_template_content)
|
|
1703
|
+
device_output = self.db_input_text.toPlainText()
|
|
1704
|
+
if device_output:
|
|
1705
|
+
self.manual_output_text.setPlainText(device_output)
|
|
1706
|
+
self.main_tabs.setCurrentIndex(1)
|
|
1707
|
+
self.statusBar().showMessage("Template loaded into Manual Test tab")
|
|
1708
|
+
else:
|
|
1709
|
+
QMessageBox.warning(self, "Warning", "No template content to load")
|
|
1710
|
+
|
|
1711
|
+
def export_db_results_json(self):
|
|
1712
|
+
"""Export database test results to JSON"""
|
|
1713
|
+
if not hasattr(self, '_db_parsed_data') or not self._db_parsed_data:
|
|
1714
|
+
QMessageBox.warning(self, "Warning", "No results to export. Run a test first.")
|
|
1715
|
+
return
|
|
1716
|
+
|
|
1717
|
+
default_name = "results.json"
|
|
1718
|
+
if hasattr(self, '_current_template_name') and self._current_template_name:
|
|
1719
|
+
default_name = f"{self._current_template_name}_results.json"
|
|
1720
|
+
|
|
1721
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
1722
|
+
self, "Export JSON", default_name, "JSON Files (*.json)"
|
|
1723
|
+
)
|
|
1724
|
+
if file_path:
|
|
1725
|
+
try:
|
|
1726
|
+
with open(file_path, 'w') as f:
|
|
1727
|
+
json.dump(self._db_parsed_data, f, indent=2)
|
|
1728
|
+
self.statusBar().showMessage(f"Exported {len(self._db_parsed_data)} records to {file_path}")
|
|
1729
|
+
except Exception as e:
|
|
1730
|
+
traceback.print_exc()
|
|
1731
|
+
QMessageBox.critical(self, "Error", f"Export failed: {str(e)}")
|
|
1732
|
+
|
|
1733
|
+
def export_db_results_csv(self):
|
|
1734
|
+
"""Export database test results to CSV"""
|
|
1735
|
+
if not hasattr(self, '_db_parsed_data') or not self._db_parsed_data:
|
|
1736
|
+
QMessageBox.warning(self, "Warning", "No results to export. Run a test first.")
|
|
1737
|
+
return
|
|
1738
|
+
|
|
1739
|
+
default_name = "results.csv"
|
|
1740
|
+
if hasattr(self, '_current_template_name') and self._current_template_name:
|
|
1741
|
+
default_name = f"{self._current_template_name}_results.csv"
|
|
1742
|
+
|
|
1743
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
1744
|
+
self, "Export CSV", default_name, "CSV Files (*.csv)"
|
|
1745
|
+
)
|
|
1746
|
+
if file_path:
|
|
1747
|
+
try:
|
|
1748
|
+
import csv
|
|
1749
|
+
headers = list(self._db_parsed_data[0].keys())
|
|
1750
|
+
with open(file_path, 'w', newline='') as f:
|
|
1751
|
+
writer = csv.DictWriter(f, fieldnames=headers)
|
|
1752
|
+
writer.writeheader()
|
|
1753
|
+
writer.writerows(self._db_parsed_data)
|
|
1754
|
+
self.statusBar().showMessage(f"Exported {len(self._db_parsed_data)} records to {file_path}")
|
|
1755
|
+
except Exception as e:
|
|
1756
|
+
traceback.print_exc()
|
|
1757
|
+
QMessageBox.critical(self, "Error", f"Export failed: {str(e)}")
|
|
1758
|
+
|
|
1759
|
+
# =========================================================================
|
|
1760
|
+
# MANUAL TEST TAB
|
|
1761
|
+
# =========================================================================
|
|
1762
|
+
|
|
1763
|
+
def load_template_file(self):
|
|
1764
|
+
file_path, _ = QFileDialog.getOpenFileName(
|
|
1765
|
+
self, "Load TextFSM Template", "", "TextFSM Files (*.textfsm *.template);;All Files (*)"
|
|
1766
|
+
)
|
|
1767
|
+
if file_path:
|
|
1768
|
+
try:
|
|
1769
|
+
with open(file_path, 'r') as f:
|
|
1770
|
+
self.manual_template_text.setPlainText(f.read())
|
|
1771
|
+
self.statusBar().showMessage(f"Loaded template: {file_path}")
|
|
1772
|
+
except Exception as e:
|
|
1773
|
+
traceback.print_exc()
|
|
1774
|
+
QMessageBox.critical(self, "Error", f"Failed to load file: {str(e)}")
|
|
1775
|
+
|
|
1776
|
+
def load_output_file(self):
|
|
1777
|
+
file_path, _ = QFileDialog.getOpenFileName(
|
|
1778
|
+
self, "Load Device Output", "", "Text Files (*.txt);;All Files (*)"
|
|
1779
|
+
)
|
|
1780
|
+
if file_path:
|
|
1781
|
+
try:
|
|
1782
|
+
with open(file_path, 'r') as f:
|
|
1783
|
+
self.manual_output_text.setPlainText(f.read())
|
|
1784
|
+
self.statusBar().showMessage(f"Loaded output: {file_path}")
|
|
1785
|
+
except Exception as e:
|
|
1786
|
+
traceback.print_exc()
|
|
1787
|
+
QMessageBox.critical(self, "Error", f"Failed to load file: {str(e)}")
|
|
1788
|
+
|
|
1789
|
+
def load_sample_template(self):
|
|
1790
|
+
sample = """Value NEIGHBOR (\\S+)
|
|
1791
|
+
Value LOCAL_INTERFACE (\\S+)
|
|
1792
|
+
Value HOLD_TIME (\\d+)
|
|
1793
|
+
Value CAPABILITY (\\S+)
|
|
1794
|
+
Value NEIGHBOR_INTERFACE (\\S+)
|
|
1795
|
+
|
|
1796
|
+
Start
|
|
1797
|
+
^${NEIGHBOR}\\s+${LOCAL_INTERFACE}\\s+${HOLD_TIME}\\s+${CAPABILITY}\\s+${NEIGHBOR_INTERFACE} -> Record
|
|
1798
|
+
|
|
1799
|
+
End"""
|
|
1800
|
+
self.manual_template_text.setPlainText(sample)
|
|
1801
|
+
|
|
1802
|
+
def load_sample_manual_output(self):
|
|
1803
|
+
sample = """Device ID Local Intf Hold-time Capability Port ID
|
|
1804
|
+
usa-spine-1 Eth2 120 B,R Ethernet2
|
|
1805
|
+
usa-rtr-1 Eth1 120 R GigabitEthernet0/2
|
|
1806
|
+
usa-leaf-3 Eth3 120 R GigabitEthernet0/0
|
|
1807
|
+
usa-leaf-2 Eth4 120 R GigabitEthernet0/0
|
|
1808
|
+
usa-leaf-1 Eth5 120 R GigabitEthernet0/0"""
|
|
1809
|
+
self.manual_output_text.setPlainText(sample)
|
|
1810
|
+
|
|
1811
|
+
def test_manual_template(self):
|
|
1812
|
+
template_content = self.manual_template_text.toPlainText().strip()
|
|
1813
|
+
device_output = self.manual_output_text.toPlainText().strip()
|
|
1814
|
+
|
|
1815
|
+
if not template_content:
|
|
1816
|
+
QMessageBox.warning(self, "Warning", "Please enter a TextFSM template")
|
|
1817
|
+
return
|
|
1818
|
+
|
|
1819
|
+
if not device_output:
|
|
1820
|
+
QMessageBox.warning(self, "Warning", "Please enter device output")
|
|
1821
|
+
return
|
|
1822
|
+
|
|
1823
|
+
self.manual_test_btn.setEnabled(False)
|
|
1824
|
+
self.statusBar().showMessage("Parsing...")
|
|
1825
|
+
|
|
1826
|
+
self.manual_worker = ManualTestWorker(template_content, device_output)
|
|
1827
|
+
self.manual_worker.results_ready.connect(self.handle_manual_results)
|
|
1828
|
+
self.manual_worker.start()
|
|
1829
|
+
|
|
1830
|
+
def handle_manual_results(self, headers: list, data: list, error: str):
|
|
1831
|
+
self.manual_test_btn.setEnabled(True)
|
|
1832
|
+
|
|
1833
|
+
if error:
|
|
1834
|
+
self.manual_status_label.setText(f"Error: {error}")
|
|
1835
|
+
self.manual_results_table.setRowCount(0)
|
|
1836
|
+
self.manual_results_table.setColumnCount(0)
|
|
1837
|
+
self.statusBar().showMessage("Parse failed")
|
|
1838
|
+
return
|
|
1839
|
+
|
|
1840
|
+
self.manual_status_label.setText(f"Successfully parsed {len(data)} records with {len(headers)} fields")
|
|
1841
|
+
self.statusBar().showMessage(f"Parsed {len(data)} records")
|
|
1842
|
+
|
|
1843
|
+
# Populate table
|
|
1844
|
+
self.manual_results_table.setRowCount(len(data))
|
|
1845
|
+
self.manual_results_table.setColumnCount(len(headers))
|
|
1846
|
+
self.manual_results_table.setHorizontalHeaderLabels(headers)
|
|
1847
|
+
|
|
1848
|
+
for row_idx, row in enumerate(data):
|
|
1849
|
+
for col_idx, value in enumerate(row):
|
|
1850
|
+
self.manual_results_table.setItem(row_idx, col_idx, QTableWidgetItem(str(value)))
|
|
1851
|
+
|
|
1852
|
+
# Store for export
|
|
1853
|
+
self._manual_headers = headers
|
|
1854
|
+
self._manual_data = data
|
|
1855
|
+
|
|
1856
|
+
def export_manual_results_json(self):
|
|
1857
|
+
if not hasattr(self, '_manual_data') or not self._manual_data:
|
|
1858
|
+
QMessageBox.warning(self, "Warning", "No results to export")
|
|
1859
|
+
return
|
|
1860
|
+
|
|
1861
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
1862
|
+
self, "Export JSON", "results.json", "JSON Files (*.json)"
|
|
1863
|
+
)
|
|
1864
|
+
if file_path:
|
|
1865
|
+
try:
|
|
1866
|
+
results = [dict(zip(self._manual_headers, row)) for row in self._manual_data]
|
|
1867
|
+
with open(file_path, 'w') as f:
|
|
1868
|
+
json.dump(results, f, indent=2)
|
|
1869
|
+
self.statusBar().showMessage(f"Exported to {file_path}")
|
|
1870
|
+
except Exception as e:
|
|
1871
|
+
traceback.print_exc()
|
|
1872
|
+
QMessageBox.critical(self, "Error", f"Export failed: {str(e)}")
|
|
1873
|
+
|
|
1874
|
+
def export_manual_results_csv(self):
|
|
1875
|
+
if not hasattr(self, '_manual_data') or not self._manual_data:
|
|
1876
|
+
QMessageBox.warning(self, "Warning", "No results to export")
|
|
1877
|
+
return
|
|
1878
|
+
|
|
1879
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
1880
|
+
self, "Export CSV", "results.csv", "CSV Files (*.csv)"
|
|
1881
|
+
)
|
|
1882
|
+
if file_path:
|
|
1883
|
+
try:
|
|
1884
|
+
import csv
|
|
1885
|
+
with open(file_path, 'w', newline='') as f:
|
|
1886
|
+
writer = csv.writer(f)
|
|
1887
|
+
writer.writerow(self._manual_headers)
|
|
1888
|
+
writer.writerows(self._manual_data)
|
|
1889
|
+
self.statusBar().showMessage(f"Exported to {file_path}")
|
|
1890
|
+
except Exception as e:
|
|
1891
|
+
traceback.print_exc()
|
|
1892
|
+
QMessageBox.critical(self, "Error", f"Export failed: {str(e)}")
|
|
1893
|
+
|
|
1894
|
+
def save_manual_template_to_db(self):
|
|
1895
|
+
template_content = self.manual_template_text.toPlainText().strip()
|
|
1896
|
+
if not template_content:
|
|
1897
|
+
QMessageBox.warning(self, "Warning", "No template to save")
|
|
1898
|
+
return
|
|
1899
|
+
|
|
1900
|
+
# Validate template first
|
|
1901
|
+
try:
|
|
1902
|
+
textfsm.TextFSM(io.StringIO(template_content))
|
|
1903
|
+
except Exception as e:
|
|
1904
|
+
traceback.print_exc()
|
|
1905
|
+
QMessageBox.critical(self, "Error", f"Invalid template: {str(e)}")
|
|
1906
|
+
return
|
|
1907
|
+
|
|
1908
|
+
name, ok = QInputDialog.getText(
|
|
1909
|
+
self, "Save Template", "Enter CLI command name (e.g., cisco_ios_show_ip_arp):"
|
|
1910
|
+
)
|
|
1911
|
+
if ok and name:
|
|
1912
|
+
conn = self.get_db_connection()
|
|
1913
|
+
if conn:
|
|
1914
|
+
try:
|
|
1915
|
+
cursor = conn.cursor()
|
|
1916
|
+
cursor.execute("""
|
|
1917
|
+
INSERT INTO templates (cli_command, textfsm_content, textfsm_hash, source, created)
|
|
1918
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1919
|
+
""", (
|
|
1920
|
+
name,
|
|
1921
|
+
template_content,
|
|
1922
|
+
hashlib.md5(template_content.encode()).hexdigest(),
|
|
1923
|
+
'manual',
|
|
1924
|
+
datetime.now().isoformat()
|
|
1925
|
+
))
|
|
1926
|
+
conn.commit()
|
|
1927
|
+
conn.close()
|
|
1928
|
+
self.statusBar().showMessage(f"Template saved: {name}")
|
|
1929
|
+
QMessageBox.information(self, "Success", f"Template '{name}' saved to database")
|
|
1930
|
+
self.load_all_templates()
|
|
1931
|
+
except Exception as e:
|
|
1932
|
+
traceback.print_exc()
|
|
1933
|
+
QMessageBox.critical(self, "Error", f"Failed to save: {str(e)}")
|
|
1934
|
+
|
|
1935
|
+
# =========================================================================
|
|
1936
|
+
# TEMPLATE MANAGER TAB
|
|
1937
|
+
# =========================================================================
|
|
1938
|
+
|
|
1939
|
+
def load_all_templates(self):
|
|
1940
|
+
conn = self.get_db_connection()
|
|
1941
|
+
if not conn:
|
|
1942
|
+
return
|
|
1943
|
+
|
|
1944
|
+
try:
|
|
1945
|
+
cursor = conn.cursor()
|
|
1946
|
+
cursor.execute("SELECT id, cli_command, source, created, textfsm_hash FROM templates ORDER BY cli_command")
|
|
1947
|
+
templates = cursor.fetchall()
|
|
1948
|
+
conn.close()
|
|
1949
|
+
|
|
1950
|
+
self.mgr_table.setRowCount(len(templates))
|
|
1951
|
+
for row, t in enumerate(templates):
|
|
1952
|
+
self.mgr_table.setItem(row, 0, QTableWidgetItem(str(t['id'])))
|
|
1953
|
+
self.mgr_table.setItem(row, 1, QTableWidgetItem(t['cli_command'] or ''))
|
|
1954
|
+
self.mgr_table.setItem(row, 2, QTableWidgetItem(t['source'] or ''))
|
|
1955
|
+
self.mgr_table.setItem(row, 3, QTableWidgetItem(t['created'] or ''))
|
|
1956
|
+
self.mgr_table.setItem(row, 4, QTableWidgetItem(t['textfsm_hash'] or ''))
|
|
1957
|
+
|
|
1958
|
+
self.statusBar().showMessage(f"Loaded {len(templates)} templates")
|
|
1959
|
+
self._all_templates = templates
|
|
1960
|
+
|
|
1961
|
+
except Exception as e:
|
|
1962
|
+
traceback.print_exc()
|
|
1963
|
+
QMessageBox.critical(self, "Error", f"Failed to load templates: {str(e)}")
|
|
1964
|
+
|
|
1965
|
+
def filter_templates(self, text: str):
|
|
1966
|
+
if not hasattr(self, '_all_templates'):
|
|
1967
|
+
return
|
|
1968
|
+
|
|
1969
|
+
search = text.lower()
|
|
1970
|
+
for row in range(self.mgr_table.rowCount()):
|
|
1971
|
+
item = self.mgr_table.item(row, 1)
|
|
1972
|
+
if item:
|
|
1973
|
+
match = search in item.text().lower()
|
|
1974
|
+
self.mgr_table.setRowHidden(row, not match)
|
|
1975
|
+
|
|
1976
|
+
def update_template_preview(self):
|
|
1977
|
+
selected = self.mgr_table.selectedItems()
|
|
1978
|
+
if not selected:
|
|
1979
|
+
self.mgr_preview_text.clear()
|
|
1980
|
+
return
|
|
1981
|
+
|
|
1982
|
+
row = selected[0].row()
|
|
1983
|
+
template_id = self.mgr_table.item(row, 0).text()
|
|
1984
|
+
|
|
1985
|
+
conn = self.get_db_connection()
|
|
1986
|
+
if conn:
|
|
1987
|
+
try:
|
|
1988
|
+
cursor = conn.cursor()
|
|
1989
|
+
cursor.execute("SELECT textfsm_content FROM templates WHERE id = ?", (template_id,))
|
|
1990
|
+
result = cursor.fetchone()
|
|
1991
|
+
conn.close()
|
|
1992
|
+
|
|
1993
|
+
if result:
|
|
1994
|
+
self.mgr_preview_text.setPlainText(result['textfsm_content'])
|
|
1995
|
+
except Exception as e:
|
|
1996
|
+
traceback.print_exc()
|
|
1997
|
+
self.mgr_preview_text.setPlainText(f"Error loading preview: {str(e)}")
|
|
1998
|
+
|
|
1999
|
+
def show_template_context_menu(self, pos):
|
|
2000
|
+
menu = QMenu(self)
|
|
2001
|
+
|
|
2002
|
+
edit_action = menu.addAction("Edit")
|
|
2003
|
+
edit_action.triggered.connect(self.edit_selected_template)
|
|
2004
|
+
|
|
2005
|
+
duplicate_action = menu.addAction("Duplicate")
|
|
2006
|
+
duplicate_action.triggered.connect(self.duplicate_selected_template)
|
|
2007
|
+
|
|
2008
|
+
menu.addSeparator()
|
|
2009
|
+
|
|
2010
|
+
test_action = menu.addAction("Test in Manual Tab")
|
|
2011
|
+
test_action.triggered.connect(self.test_selected_in_manual)
|
|
2012
|
+
|
|
2013
|
+
menu.addSeparator()
|
|
2014
|
+
|
|
2015
|
+
delete_action = menu.addAction("Delete")
|
|
2016
|
+
delete_action.triggered.connect(self.delete_selected_template)
|
|
2017
|
+
|
|
2018
|
+
menu.exec(self.mgr_table.viewport().mapToGlobal(pos))
|
|
2019
|
+
|
|
2020
|
+
def add_template(self):
|
|
2021
|
+
dialog = TemplateEditorDialog(self)
|
|
2022
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
2023
|
+
data = dialog.get_template_data()
|
|
2024
|
+
|
|
2025
|
+
conn = self.get_db_connection()
|
|
2026
|
+
if conn:
|
|
2027
|
+
try:
|
|
2028
|
+
cursor = conn.cursor()
|
|
2029
|
+
cursor.execute("""
|
|
2030
|
+
INSERT INTO templates (cli_command, cli_content, textfsm_content, textfsm_hash, source, created)
|
|
2031
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
2032
|
+
""", (
|
|
2033
|
+
data['cli_command'],
|
|
2034
|
+
data['cli_content'],
|
|
2035
|
+
data['textfsm_content'],
|
|
2036
|
+
data['textfsm_hash'],
|
|
2037
|
+
data['source'],
|
|
2038
|
+
data['created']
|
|
2039
|
+
))
|
|
2040
|
+
conn.commit()
|
|
2041
|
+
conn.close()
|
|
2042
|
+
|
|
2043
|
+
self.statusBar().showMessage(f"Added template: {data['cli_command']}")
|
|
2044
|
+
self.load_all_templates()
|
|
2045
|
+
except Exception as e:
|
|
2046
|
+
traceback.print_exc()
|
|
2047
|
+
QMessageBox.critical(self, "Error", f"Failed to add template: {str(e)}")
|
|
2048
|
+
|
|
2049
|
+
def edit_selected_template(self):
|
|
2050
|
+
selected = self.mgr_table.selectedItems()
|
|
2051
|
+
if not selected:
|
|
2052
|
+
QMessageBox.warning(self, "Warning", "Please select a template to edit")
|
|
2053
|
+
return
|
|
2054
|
+
|
|
2055
|
+
row = selected[0].row()
|
|
2056
|
+
template_id = self.mgr_table.item(row, 0).text()
|
|
2057
|
+
|
|
2058
|
+
conn = self.get_db_connection()
|
|
2059
|
+
if conn:
|
|
2060
|
+
try:
|
|
2061
|
+
cursor = conn.cursor()
|
|
2062
|
+
cursor.execute("SELECT * FROM templates WHERE id = ?", (template_id,))
|
|
2063
|
+
template = cursor.fetchone()
|
|
2064
|
+
conn.close()
|
|
2065
|
+
|
|
2066
|
+
if template:
|
|
2067
|
+
dialog = TemplateEditorDialog(self, dict(template))
|
|
2068
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
2069
|
+
data = dialog.get_template_data()
|
|
2070
|
+
|
|
2071
|
+
conn = self.get_db_connection()
|
|
2072
|
+
cursor = conn.cursor()
|
|
2073
|
+
cursor.execute("""
|
|
2074
|
+
UPDATE templates SET
|
|
2075
|
+
cli_command = ?, cli_content = ?, textfsm_content = ?,
|
|
2076
|
+
textfsm_hash = ?, source = ?
|
|
2077
|
+
WHERE id = ?
|
|
2078
|
+
""", (
|
|
2079
|
+
data['cli_command'],
|
|
2080
|
+
data['cli_content'],
|
|
2081
|
+
data['textfsm_content'],
|
|
2082
|
+
data['textfsm_hash'],
|
|
2083
|
+
data['source'],
|
|
2084
|
+
template_id
|
|
2085
|
+
))
|
|
2086
|
+
conn.commit()
|
|
2087
|
+
conn.close()
|
|
2088
|
+
|
|
2089
|
+
self.statusBar().showMessage(f"Updated template: {data['cli_command']}")
|
|
2090
|
+
self.load_all_templates()
|
|
2091
|
+
except Exception as e:
|
|
2092
|
+
traceback.print_exc()
|
|
2093
|
+
QMessageBox.critical(self, "Error", f"Failed to edit template: {str(e)}")
|
|
2094
|
+
|
|
2095
|
+
def delete_selected_template(self):
|
|
2096
|
+
selected = self.mgr_table.selectedItems()
|
|
2097
|
+
if not selected:
|
|
2098
|
+
QMessageBox.warning(self, "Warning", "Please select a template to delete")
|
|
2099
|
+
return
|
|
2100
|
+
|
|
2101
|
+
row = selected[0].row()
|
|
2102
|
+
template_id = self.mgr_table.item(row, 0).text()
|
|
2103
|
+
cli_command = self.mgr_table.item(row, 1).text()
|
|
2104
|
+
|
|
2105
|
+
reply = QMessageBox.question(
|
|
2106
|
+
self, "Confirm Delete",
|
|
2107
|
+
f"Are you sure you want to delete '{cli_command}'?",
|
|
2108
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
|
2109
|
+
)
|
|
2110
|
+
|
|
2111
|
+
if reply == QMessageBox.StandardButton.Yes:
|
|
2112
|
+
conn = self.get_db_connection()
|
|
2113
|
+
if conn:
|
|
2114
|
+
try:
|
|
2115
|
+
cursor = conn.cursor()
|
|
2116
|
+
cursor.execute("DELETE FROM templates WHERE id = ?", (template_id,))
|
|
2117
|
+
conn.commit()
|
|
2118
|
+
conn.close()
|
|
2119
|
+
|
|
2120
|
+
self.statusBar().showMessage(f"Deleted template: {cli_command}")
|
|
2121
|
+
self.load_all_templates()
|
|
2122
|
+
except Exception as e:
|
|
2123
|
+
traceback.print_exc()
|
|
2124
|
+
QMessageBox.critical(self, "Error", f"Failed to delete: {str(e)}")
|
|
2125
|
+
|
|
2126
|
+
def duplicate_selected_template(self):
|
|
2127
|
+
selected = self.mgr_table.selectedItems()
|
|
2128
|
+
if not selected:
|
|
2129
|
+
return
|
|
2130
|
+
|
|
2131
|
+
row = selected[0].row()
|
|
2132
|
+
template_id = self.mgr_table.item(row, 0).text()
|
|
2133
|
+
|
|
2134
|
+
conn = self.get_db_connection()
|
|
2135
|
+
if conn:
|
|
2136
|
+
try:
|
|
2137
|
+
cursor = conn.cursor()
|
|
2138
|
+
cursor.execute("SELECT * FROM templates WHERE id = ?", (template_id,))
|
|
2139
|
+
template = dict(cursor.fetchone())
|
|
2140
|
+
|
|
2141
|
+
template['cli_command'] = template['cli_command'] + '_copy'
|
|
2142
|
+
template['created'] = datetime.now().isoformat()
|
|
2143
|
+
|
|
2144
|
+
cursor.execute("""
|
|
2145
|
+
INSERT INTO templates (cli_command, cli_content, textfsm_content, textfsm_hash, source, created)
|
|
2146
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
2147
|
+
""", (
|
|
2148
|
+
template['cli_command'],
|
|
2149
|
+
template.get('cli_content', ''),
|
|
2150
|
+
template['textfsm_content'],
|
|
2151
|
+
template.get('textfsm_hash', ''),
|
|
2152
|
+
template.get('source', 'duplicate'),
|
|
2153
|
+
template['created']
|
|
2154
|
+
))
|
|
2155
|
+
conn.commit()
|
|
2156
|
+
conn.close()
|
|
2157
|
+
|
|
2158
|
+
self.statusBar().showMessage(f"Duplicated template: {template['cli_command']}")
|
|
2159
|
+
self.load_all_templates()
|
|
2160
|
+
except Exception as e:
|
|
2161
|
+
traceback.print_exc()
|
|
2162
|
+
QMessageBox.critical(self, "Error", f"Failed to duplicate: {str(e)}")
|
|
2163
|
+
|
|
2164
|
+
def test_selected_in_manual(self):
|
|
2165
|
+
selected = self.mgr_table.selectedItems()
|
|
2166
|
+
if not selected:
|
|
2167
|
+
return
|
|
2168
|
+
|
|
2169
|
+
row = selected[0].row()
|
|
2170
|
+
template_id = self.mgr_table.item(row, 0).text()
|
|
2171
|
+
|
|
2172
|
+
conn = self.get_db_connection()
|
|
2173
|
+
if conn:
|
|
2174
|
+
try:
|
|
2175
|
+
cursor = conn.cursor()
|
|
2176
|
+
cursor.execute("SELECT textfsm_content FROM templates WHERE id = ?", (template_id,))
|
|
2177
|
+
result = cursor.fetchone()
|
|
2178
|
+
conn.close()
|
|
2179
|
+
|
|
2180
|
+
if result:
|
|
2181
|
+
self.manual_template_text.setPlainText(result['textfsm_content'])
|
|
2182
|
+
self.main_tabs.setCurrentIndex(1)
|
|
2183
|
+
self.statusBar().showMessage("Template loaded into Manual Test tab")
|
|
2184
|
+
except Exception as e:
|
|
2185
|
+
traceback.print_exc()
|
|
2186
|
+
QMessageBox.critical(self, "Error", f"Failed to load template: {str(e)}")
|
|
2187
|
+
|
|
2188
|
+
def import_from_ntc(self):
|
|
2189
|
+
"""Import templates from ntc-templates directory"""
|
|
2190
|
+
dir_path = QFileDialog.getExistingDirectory(
|
|
2191
|
+
self, "Select ntc-templates Directory"
|
|
2192
|
+
)
|
|
2193
|
+
if not dir_path:
|
|
2194
|
+
return
|
|
2195
|
+
|
|
2196
|
+
templates_dir = Path(dir_path)
|
|
2197
|
+
if not templates_dir.exists():
|
|
2198
|
+
QMessageBox.critical(self, "Error", "Directory not found")
|
|
2199
|
+
return
|
|
2200
|
+
|
|
2201
|
+
template_files = list(templates_dir.glob("**/*.textfsm"))
|
|
2202
|
+
if not template_files:
|
|
2203
|
+
template_files = list(templates_dir.glob("**/*.template"))
|
|
2204
|
+
|
|
2205
|
+
if not template_files:
|
|
2206
|
+
QMessageBox.warning(self, "Warning", "No TextFSM template files found")
|
|
2207
|
+
return
|
|
2208
|
+
|
|
2209
|
+
conn = self.get_db_connection()
|
|
2210
|
+
if not conn:
|
|
2211
|
+
return
|
|
2212
|
+
|
|
2213
|
+
imported = 0
|
|
2214
|
+
skipped = 0
|
|
2215
|
+
|
|
2216
|
+
try:
|
|
2217
|
+
cursor = conn.cursor()
|
|
2218
|
+
|
|
2219
|
+
for file_path in template_files:
|
|
2220
|
+
try:
|
|
2221
|
+
with open(file_path, 'r') as f:
|
|
2222
|
+
content = f.read()
|
|
2223
|
+
|
|
2224
|
+
cli_command = file_path.stem
|
|
2225
|
+
|
|
2226
|
+
cursor.execute("SELECT id FROM templates WHERE cli_command = ?", (cli_command,))
|
|
2227
|
+
if cursor.fetchone():
|
|
2228
|
+
skipped += 1
|
|
2229
|
+
continue
|
|
2230
|
+
|
|
2231
|
+
cursor.execute("""
|
|
2232
|
+
INSERT INTO templates (cli_command, textfsm_content, textfsm_hash, source, created)
|
|
2233
|
+
VALUES (?, ?, ?, ?, ?)
|
|
2234
|
+
""", (
|
|
2235
|
+
cli_command,
|
|
2236
|
+
content,
|
|
2237
|
+
hashlib.md5(content.encode()).hexdigest(),
|
|
2238
|
+
'ntc-templates',
|
|
2239
|
+
datetime.now().isoformat()
|
|
2240
|
+
))
|
|
2241
|
+
imported += 1
|
|
2242
|
+
|
|
2243
|
+
except Exception as e:
|
|
2244
|
+
traceback.print_exc()
|
|
2245
|
+
print(f"Error importing {file_path}: {e}")
|
|
2246
|
+
continue
|
|
2247
|
+
|
|
2248
|
+
conn.commit()
|
|
2249
|
+
conn.close()
|
|
2250
|
+
|
|
2251
|
+
self.statusBar().showMessage(f"Imported {imported} templates, skipped {skipped} duplicates")
|
|
2252
|
+
QMessageBox.information(
|
|
2253
|
+
self, "Import Complete",
|
|
2254
|
+
f"Imported: {imported}\nSkipped (duplicates): {skipped}"
|
|
2255
|
+
)
|
|
2256
|
+
self.load_all_templates()
|
|
2257
|
+
|
|
2258
|
+
except Exception as e:
|
|
2259
|
+
traceback.print_exc()
|
|
2260
|
+
QMessageBox.critical(self, "Error", f"Import failed: {str(e)}")
|
|
2261
|
+
|
|
2262
|
+
def download_from_ntc(self):
|
|
2263
|
+
"""Download templates from ntc-templates GitHub repository"""
|
|
2264
|
+
if not REQUESTS_AVAILABLE:
|
|
2265
|
+
QMessageBox.critical(
|
|
2266
|
+
self, "Error",
|
|
2267
|
+
"requests library not available.\nInstall with: pip install requests"
|
|
2268
|
+
)
|
|
2269
|
+
return
|
|
2270
|
+
|
|
2271
|
+
dialog = NTCDownloadDialog(self, self.db_path_input.text())
|
|
2272
|
+
dialog.exec()
|
|
2273
|
+
self.load_all_templates()
|
|
2274
|
+
|
|
2275
|
+
def export_all_templates(self):
|
|
2276
|
+
"""Export all templates to a directory"""
|
|
2277
|
+
dir_path = QFileDialog.getExistingDirectory(
|
|
2278
|
+
self, "Select Export Directory"
|
|
2279
|
+
)
|
|
2280
|
+
if not dir_path:
|
|
2281
|
+
return
|
|
2282
|
+
|
|
2283
|
+
conn = self.get_db_connection()
|
|
2284
|
+
if not conn:
|
|
2285
|
+
return
|
|
2286
|
+
|
|
2287
|
+
try:
|
|
2288
|
+
cursor = conn.cursor()
|
|
2289
|
+
cursor.execute("SELECT cli_command, textfsm_content FROM templates")
|
|
2290
|
+
templates = cursor.fetchall()
|
|
2291
|
+
conn.close()
|
|
2292
|
+
|
|
2293
|
+
export_dir = Path(dir_path)
|
|
2294
|
+
exported = 0
|
|
2295
|
+
|
|
2296
|
+
for t in templates:
|
|
2297
|
+
file_path = export_dir / f"{t['cli_command']}.textfsm"
|
|
2298
|
+
with open(file_path, 'w') as f:
|
|
2299
|
+
f.write(t['textfsm_content'])
|
|
2300
|
+
exported += 1
|
|
2301
|
+
|
|
2302
|
+
self.statusBar().showMessage(f"Exported {exported} templates")
|
|
2303
|
+
QMessageBox.information(self, "Export Complete", f"Exported {exported} templates to {dir_path}")
|
|
2304
|
+
|
|
2305
|
+
except Exception as e:
|
|
2306
|
+
traceback.print_exc()
|
|
2307
|
+
QMessageBox.critical(self, "Error", f"Export failed: {str(e)}")
|
|
2308
|
+
|
|
2309
|
+
|
|
2310
|
+
# =============================================================================
|
|
2311
|
+
# MAIN
|
|
2312
|
+
# =============================================================================
|
|
2313
|
+
|
|
2314
|
+
def main():
|
|
2315
|
+
app = QApplication(sys.argv)
|
|
2316
|
+
app.setStyle('Fusion')
|
|
2317
|
+
|
|
2318
|
+
window = TextFSMTester()
|
|
2319
|
+
window.show()
|
|
2320
|
+
|
|
2321
|
+
# Load templates on startup if database exists
|
|
2322
|
+
if Path(window.db_path).exists():
|
|
2323
|
+
window.load_all_templates()
|
|
2324
|
+
|
|
2325
|
+
sys.exit(app.exec())
|
|
2326
|
+
|
|
2327
|
+
|
|
2328
|
+
if __name__ == '__main__':
|
|
2329
|
+
main()
|