#!/usr/bin/env python3 """ Support data analysis tool for Infix Analyze support data collected from Infix devices. Provides TUI for exploring logs, comparing between devices or time periods, and generating summaries. Installation: This tool requires Python 3.10+ and the 'textual' library. Option 1: Using a virtual environment (recommended): python3 -m venv ~/.venv/support source ~/.venv/support/bin/activate pip install textual rich ./support analyze ~/support/ # To use later, just activate the venv again: source ~/.venv/support/bin/activate Option 2: Using uvx (if you have uv installed): uvx --from textual --with rich ./support analyze ~/support/ Option 3: System-wide installation: pip install --user textual rich ./support analyze ~/support/ Note: System packages (apt-get/apt) often have outdated versions. Use pip with venv for the latest textual version. Usage: support analyze # Single device TUI browser support analyze # Comparison view (auto-detect) support diff [--file] # Focused diff view support summary # Quick CLI summary """ import argparse import sys from pathlib import Path class SupportArchive: """Represents a single support collection directory.""" def __init__(self, path: Path): self.path = path self.name = path.name # Parse directory name: support-- # e.g., support-infix-00-00-00-2025-11-30T06:40:58+00:00 # Timestamp is ISO 8601: YYYY-MM-DDTHH:MM:SS+TZ # So we look for the pattern YYYY-MM-DD (last occurrence of date pattern) if self.name.startswith('support-'): remainder = self.name[8:] # Remove 'support-' prefix # Find the timestamp by looking for ISO 8601 date pattern # Pattern: YYYY-MM-DDTHH:MM:SS import re match = re.search(r'(\d{4}-\d{2}-\d{2}T[\d:+-]+)$', remainder) if match: # Everything before the timestamp is the hostname timestamp_start = match.start() # hostname is everything up to (but not including) the last dash before timestamp hostname_part = remainder[:timestamp_start].rstrip('-') self.hostname = hostname_part self.timestamp = match.group(1) else: # No timestamp found self.hostname = remainder self.timestamp = None else: # Doesn't start with 'support-' self.hostname = self.name self.timestamp = None self.collection_log = path / "collection.log" self.is_valid = self.collection_log.exists() def get_file(self, relative_path: str) -> Path: """Get path to a file within the archive.""" return self.path / relative_path def list_files(self, pattern: str = "*") -> list[Path]: """List all files matching pattern (recursive).""" return sorted(self.path.rglob(pattern)) def get_structure(self) -> dict: """Get organized structure of collected data.""" structure = { "system": [], "network": [], "frr": [], "podman": [], "config": [], "logs": [], "other": [], } for file in self.path.rglob("*"): if file.is_file(): rel_path = file.relative_to(self.path) parts = rel_path.parts if len(parts) > 0: category = parts[0] if parts[0] in structure else "other" structure[category].append(rel_path) return structure def get_summary_data(self) -> dict: """Parse key files and extract summary information.""" summary = { "uptime": "unknown", "uptime_seconds": -1, # -1 means unknown, 0 is valid (just booted) "memory_percent": 0, "memory_used_mb": 0, "memory_total_mb": 0, "load_avg": "unknown", "dmesg_errors": 0, "dmesg_warnings": 0, } # Parse uptime uptime_file = self.get_file("uptime.txt") if uptime_file.exists(): try: uptime_text = uptime_file.read_text().strip() # Format: " 06:41:19 up 1 min, 1 users, load average: 0.00, 0.00, 0.00" summary["uptime"] = uptime_text # Extract load average if "load average:" in uptime_text: load_part = uptime_text.split("load average:")[1].strip() summary["load_avg"] = load_part # Extract uptime seconds (rough estimate from days/hours/minutes) import re if " day" in uptime_text: days = int(re.search(r'(\d+)\s+day', uptime_text).group(1)) summary["uptime_seconds"] = days * 86400 elif " min" in uptime_text: mins = int(re.search(r'(\d+)\s+min', uptime_text).group(1)) summary["uptime_seconds"] = mins * 60 elif ":" in uptime_text and "up" in uptime_text: # Format like "up 1:23" (hours:minutes) time_match = re.search(r'up\s+(\d+):(\d+)', uptime_text) if time_match: hours, mins = int(time_match.group(1)), int(time_match.group(2)) summary["uptime_seconds"] = hours * 3600 + mins * 60 except: pass # Parse memory from meminfo meminfo_file = self.get_file("system/meminfo.txt") if meminfo_file.exists(): try: meminfo = meminfo_file.read_text() mem_total = 0 mem_available = 0 for line in meminfo.splitlines(): if line.startswith("MemTotal:"): mem_total = int(line.split()[1]) # in KB elif line.startswith("MemAvailable:"): mem_available = int(line.split()[1]) # in KB if mem_total > 0: mem_used = mem_total - mem_available summary["memory_total_mb"] = mem_total // 1024 summary["memory_used_mb"] = mem_used // 1024 summary["memory_percent"] = int((mem_used / mem_total) * 100) except: pass # Count errors/warnings in dmesg dmesg_file = self.get_file("system/dmesg.txt") if dmesg_file.exists(): try: dmesg = dmesg_file.read_text().lower() # Count lines with error/warning (case insensitive) for line in dmesg.splitlines(): if 'error' in line or 'fail' in line: summary["dmesg_errors"] += 1 elif 'warn' in line: summary["dmesg_warnings"] += 1 except: pass return summary def __repr__(self): return f"SupportArchive({self.hostname}@{self.timestamp})" def discover_archives(directory: Path) -> list[SupportArchive]: """ Discover all support collection directories in the given path. Handles both: - Direct path to a support directory - Parent directory containing multiple support directories """ archives = [] if not directory.exists(): print(f"Error: Directory not found: {directory}", file=sys.stderr) return archives # Check if directory itself is a support archive if (directory / "collection.log").exists(): archives.append(SupportArchive(directory)) return archives # Otherwise, scan for support directories for item in directory.iterdir(): if item.is_dir() and item.name.startswith("support-"): archive = SupportArchive(item) if archive.is_valid: archives.append(archive) else: print(f"Warning: Invalid archive (no collection.log): {item}", file=sys.stderr) return sorted(archives, key=lambda a: (a.hostname, a.timestamp or "")) def detect_mode(archives: list[SupportArchive]) -> str: """ Auto-detect analysis mode based on archives. Returns: - "analyze": One archive (single archive browser) - "compare": Two archives - "summary": Multiple archives (fleet view) """ if len(archives) == 0: return "summary" elif len(archives) == 1: return "analyze" # Single archive -> analyze mode elif len(archives) == 2: return "compare" else: return "summary" # Multiple archives -> summary/fleet view def cmd_analyze(args): """Main TUI analysis command.""" # Collect all archives from provided paths all_archives = [] for path_str in args.directories: path = Path(path_str).resolve() archives = discover_archives(path) all_archives.extend(archives) if not all_archives: print("Error: No valid support archives found", file=sys.stderr) return 1 # Auto-detect or use explicit mode mode = args.mode or detect_mode(all_archives) # Launch TUI try: from support_tui import launch_tui launch_tui(all_archives, mode) return 0 except ImportError as e: print(f"Error: TUI dependencies not available: {e}", file=sys.stderr) print("Please install: pip install textual", file=sys.stderr) return 1 except Exception as e: print(f"Error launching TUI: {e}", file=sys.stderr) return 1 def cmd_diff(args): """Diff command for comparing two archives.""" dir1 = Path(args.dir1).resolve() dir2 = Path(args.dir2).resolve() archives1 = discover_archives(dir1) archives2 = discover_archives(dir2) if not archives1 or not archives2: print("Error: Could not find valid archives in provided paths", file=sys.stderr) return 1 archive1 = archives1[0] archive2 = archives2[0] print(f"Comparing:") print(f" {archive1.name}") print(f" {archive2.name}") if args.file: print(f"\nFile: {args.file}") print("\nDiff view not yet implemented - coming next!") # TODO: Launch diff TUI return 0 def cmd_summary(args): """Generate quick summary of archives.""" for path_str in args.directories: path = Path(path_str).resolve() archives = discover_archives(path) for archive in archives: print(f"\n{archive.hostname} @ {archive.timestamp or 'unknown'}") print("─" * 60) # Try to read some basic info hostname_file = archive.get_file("hostname.txt") if hostname_file.exists(): hostname = hostname_file.read_text().strip() print(f"Hostname: {hostname}") uptime_file = archive.get_file("uptime.txt") if uptime_file.exists(): uptime = uptime_file.read_text().strip() print(f"Uptime: {uptime}") # Show structure structure = archive.get_structure() print("\nCollected data:") for category, files in structure.items(): if files: print(f" {category}: {len(files)} files") return 0 def main(): # Special handling: if no args or first arg doesn't look like a command, treat as TUI launch if len(sys.argv) > 1 and sys.argv[1] not in ['analyze', 'diff', 'summary', '-h', '--help']: # Looks like directories were provided without a command - launch TUI all_archives = [] for path_str in sys.argv[1:]: if path_str.startswith('-'): print(f"Error: Unknown option: {path_str}", file=sys.stderr) print("Usage: support [directory...]", file=sys.stderr) print(" or: support [options]", file=sys.stderr) return 1 path = Path(path_str).resolve() archives = discover_archives(path) all_archives.extend(archives) if not all_archives: print("Error: No valid support archives found", file=sys.stderr) return 1 # Launch TUI with summary as default try: from support_tui import launch_tui launch_tui(all_archives, mode="summary") return 0 except ImportError as e: print(f"Error: TUI dependencies not available: {e}", file=sys.stderr) print("Please install: pip install textual rich", file=sys.stderr) return 1 except Exception as e: print(f"Error launching TUI: {e}", file=sys.stderr) import traceback traceback.print_exc() return 1 # Otherwise, parse as normal with subcommands parser = argparse.ArgumentParser( description="Analyze support data from Infix devices", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s ~/support/ # Launch TUI (default: summary view) %(prog)s ~/support/support-router1-* # Launch TUI with specific archives %(prog)s analyze ~/support/ --mode compare # Force compare mode %(prog)s diff archive1/ archive2/ %(prog)s summary ~/support/ """ ) subparsers = parser.add_subparsers(dest="command", required=True) # analyze command analyze_parser = subparsers.add_parser( "analyze", help="Interactive TUI for exploring support data" ) analyze_parser.add_argument( "directories", nargs="+", help="Support archive directories to analyze" ) analyze_parser.add_argument( "--mode", choices=["analyze", "compare", "summary"], help="Force specific analysis mode (auto-detected by default)" ) # diff command diff_parser = subparsers.add_parser( "diff", help="Compare two support archives" ) diff_parser.add_argument("dir1", help="First archive directory") diff_parser.add_argument("dir2", help="Second archive directory") diff_parser.add_argument( "--file", help="Specific file to compare (relative path within archive)" ) # summary command summary_parser = subparsers.add_parser( "summary", help="Generate quick text summary of archive(s)" ) summary_parser.add_argument( "directories", nargs="+", help="Support archive directories to summarize" ) args = parser.parse_args() # Dispatch to appropriate command handler if args.command == "analyze": return cmd_analyze(args) elif args.command == "diff": return cmd_diff(args) elif args.command == "summary": return cmd_summary(args) else: parser.print_help() return 1 if __name__ == "__main__": sys.exit(main())