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.
@@ -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()