Merge pull request #1287 from kernelkit/support

Support data collection
This commit is contained in:
Joachim Wiberg
2025-12-02 15:22:43 +01:00
committed by GitHub
17 changed files with 2360 additions and 6 deletions
+4
View File
@@ -15,6 +15,10 @@ All notable changes to the project are documented in this file.
### Changes
- New `support` command for collecting system diagnostics to aid in both
troubleshooting and support. Run `support collect > data.tar.gz`
locally or remotely via SSH to gather configuration, logs, network state,
and system information
- Upgrade Buildroot to 2025.02.8 (LTS)
- Upgrade Linux kernel to 6.12.60 (LTS)
- Initial support for 32-bit ARM systems, reference board: Raspberry Pi 2B
+88
View File
@@ -323,6 +323,94 @@ reference ID, stratum, time offsets, frequency, and root delay.
> The system uses `chronyd` Network Time Protocol (NTP) daemon. The
> output shown here is best explained in the [Chrony documentation][4].
## Support Data Collection
When troubleshooting issues or seeking support, the `support` command
provides a convenient way to collect comprehensive system diagnostics.
This command gathers configuration files, logs, network state, and other
system information into a single compressed archive.
### Collecting Support Data
To collect support data and save it to a file:
```bash
admin@host:~$ support collect > support-data.tar.gz
(admin@host) Password: ***********
Starting support data collection from host...
This may take up to a minute. Please wait...
Tailing /var/log/messages for 30 seconds (please wait)...
Log tail complete.
Collection complete. Creating archive...
admin@host:~$ ls -l support-data.tar.gz
-rw-rw-r-- 1 admin admin 508362 nov 30 13:05 support-data.tar.gz
```
The command can also be run remotely via SSH from your workstation:
```bash
$ ssh admin@host support collect > support-data.tar.gz
...
```
The collection process may take up to a minute depending on system load
and the amount of logging data. Progress messages are shown during the
collection process.
### Encrypted Collection
For secure transmission of support data, the archive can be encrypted
with GPG using a password:
```bash
admin@host:~$ support collect -p mypassword > support-data.tar.gz.gpg
Starting support data collection from host...
This may take up to a minute. Please wait...
...
Collection complete. Creating archive...
Encrypting with GPG...
```
The `support collect` command even supports omitting `mypassword` and
will then prompt interactively for the password. This works over SSH too,
but the local ssh client may then echo the password.
> [!TIP]
> To hide the encryption password for an SSH session, the script supports reading from stdin:
> `echo "$MYSECRET" | ssh user@device support collect -p > file.tar.gz.gpg`
After transferring the resulting file to your workstation, decrypt it
with the password:
```bash
$ gpg -d support-data.tar.gz.gpg > support-data.tar.gz
$ tar xzf support-data.tar.gz
```
or
```bash
$ gpg -d support-data.tar.gz.gpg | tar xz
```
> [!IMPORTANT]
> Make sure to share `mypassword` out-of-band from the encrypted data
> with the recipient of the data. I.e., avoid sending both in the same
> plain-text email for example.
### What is Collected
The support archive includes:
- System identification (hostname, uptime, kernel version)
- Running and operational configuration (sysrepo datastores)
- System logs (`/var/log` directory and live tail of messages log)
- Network configuration and state (interfaces, routes, neighbors, bridges)
- FRRouting information (OSPF, BFD status)
- Container information (podman containers and their configuration)
- System resource usage (CPU, memory, disk, processes)
- Hardware information (PCI, USB devices, network interfaces)
[1]: https://www.rfc-editor.org/rfc/rfc7317
[2]: https://github.com/kernelkit/infix/blob/main/src/confd/yang/infix-system%402024-02-29.yang
[3]: https://www.rfc-editor.org/rfc/rfc8341
+1
View File
@@ -2,6 +2,7 @@ DISTCLEANFILES = *~ *.d
ACLOCAL_AMFLAGS = -I m4
bin_PROGRAMS = copy erase files
sbin_SCRIPTS = support
# Bash completion
bashcompdir = $(datadir)/bash-completion/completions
+566
View File
@@ -0,0 +1,566 @@
#!/bin/sh
# Support utilities for troubleshooting Infix systems
#
# The collect command gathers system information and outputs a tarball.
# Data is collected to /var/lib/support (or $HOME as fallback) and then
# streamed to stdout. The temporary directory is cleaned up automatically.
#
# The following text is primarily intended for users of older Infix
# systems that do not yet have this script in the root fileystems.
#
# 1. Copy this script to the target device's home directory:
# scp support user@device:
#
# 2. SSH to the device and make it executable:
# ssh user@device
# chmod +x ~/support
#
# 3. Run the script from your home directory:
#
# ~/support collect > support-data.tar.gz
#
# Or directly via SSH from your workstation:
#
# ssh user@device '~/support collect' > support-data.tar.gz
#
# Optionally, the output can be encrypted with GPG using a password for
# secure transmission to support personnel, see below.
#
# Examples:
# support collect > support-data.tar.gz
# support collect -s 5 > support-data.tar.gz
# support collect -p > support-data.tar.gz.gpg
# support collect -p mypass > support-data.tar.gz.gpg
#
# ssh user@device support collect > support-data.tar.gz
# ssh user@device support collect -p mypass > support-data.tar.gz.gpg
#
# Note, interactive password prompt (-p without argument) may echo characters
# over SSH due to local terminal echo. Use -p PASSWORD for remote execution,
# or pipe the password: echo "password" | ssh user@device support collect -p
# meaning you can even: echo "$SECRET_VARIABLE" | ... which in some cases can
# come in handy.
#
# TODO:
# Add more commands, e.g., verify (run health checks), upload <file>,
# test <network|disk|routing>, backup, watch (dashboard view), diff
#
cmd_collect()
{
# Default values
LOG_TAIL_SEC=30
PASSWORD=""
# Parse options
while [ $# -gt 0 ]; do
case "$1" in
--log-sec|-s)
if [ -z "$2" ] || [ "$2" -le 0 ] 2>/dev/null; then
echo "Error: --log-sec requires a positive number" >&2
exit 1
fi
LOG_TAIL_SEC="$2"
shift 2
;;
--password|-p)
# If next arg exists and doesn't start with -, use it as password
if [ -n "$2" ] && [ "${2#-}" = "$2" ]; then
PASSWORD="$2"
shift 2
else
# Prompt for password interactively from stdin, no echo!
# Disable echo BEFORE printing the prompt
old_stty=$(stty -g 2>/dev/null)
stty -echo 2>/dev/null || true
printf "Enter encryption password: " >&2
read -r PASSWORD
echo "" >&2
# Restore terminal settings
if [ -n "$old_stty" ]; then
stty "$old_stty" 2>/dev/null || true
else
stty echo 2>/dev/null || true
fi
if [ -z "$PASSWORD" ]; then
echo "Error: Empty password not allowed" >&2
exit 1
fi
shift
fi
;;
*)
echo "Error: Unknown option '$1'" >&2
echo "Usage: support collect [--log-sec|-s N] [--password|-p PASSWORD]" >&2
exit 1
;;
esac
done
# Determine if we need sudo and if it's available
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
fi
# If WORK_DIR not set globally, try /var/lib/support first (more space,
# persistent across user sessions). Fall back to $HOME if we can't create/write there
if [ -z "$WORK_DIR" ]; then
if [ -w /var/lib/support ] 2>/dev/null; then
WORK_DIR="/var/lib/support"
elif mkdir -p /var/lib/support 2>/dev/null; then
WORK_DIR="/var/lib/support"
elif [ -n "$SUDO" ] && $SUDO mkdir -p /var/lib/support 2>/dev/null && \
$SUDO chown "$(id -u):$(id -g)" /var/lib/support 2>/dev/null; then
WORK_DIR="/var/lib/support"
else
WORK_DIR="${HOME}"
echo "Warning: Cannot write to /var/lib/support, using home directory instead." >&2
echo " (This may fill up your home directory on systems with limited space)" >&2
fi
fi
# Create unique collection directory
COLLECT_DIR="${WORK_DIR}/support-$(hostname -s)-$(date -Iseconds)"
EXEC_LOG="${COLLECT_DIR}/collection.log"
# Cleanup on exit
cleanup()
{
if [ -d "${COLLECT_DIR}" ]; then
rm -rf "${COLLECT_DIR}"
fi
}
trap cleanup EXIT INT TERM
# Create collection directory
mkdir -p "${COLLECT_DIR}"
# Helper function to run commands with output to specific file
collect()
{
output_file="$1"
shift
cmd_desc="$*"
mkdir -p "${COLLECT_DIR}/$(dirname "$output_file")"
echo "[$(date -Iseconds)] Collecting: $cmd_desc -> ${output_file}" >> "${EXEC_LOG}" 2>&1
if "$@" > "${COLLECT_DIR}/${output_file}" 2>> "${EXEC_LOG}"; then
echo "[$(date -Iseconds)] Success: ${output_file}" >> "${EXEC_LOG}" 2>&1
else
exit_code=$?
echo "[$(date -Iseconds)] Failed (exit ${exit_code}): ${output_file}" >> "${EXEC_LOG}" 2>&1
# Create placeholder file indicating failure
echo "Command failed with exit code ${exit_code}: $cmd_desc" > "${COLLECT_DIR}/${output_file}"
fi
}
# Start collection log
echo "=== Infix Support Data Collection ===" > "${EXEC_LOG}"
echo "Started: $(date -Iseconds)" >> "${EXEC_LOG}"
echo "Hostname: $(hostname)" >> "${EXEC_LOG}"
echo "Work directory: ${WORK_DIR}" >> "${EXEC_LOG}"
echo "Collection directory: ${COLLECT_DIR}" >> "${EXEC_LOG}"
echo "" >> "${EXEC_LOG}"
# Inform user that collection is starting (to stderr for SSH visibility)
echo "Starting support data collection from $(hostname)..." >&2
echo "Collecting to: ${WORK_DIR}" >&2
echo "This may take up to a minute. Please wait..." >&2
# System identification
collect system-info.txt uname -a
collect hostname.txt hostname
collect uptime.txt uptime
# Configuration files
collect running-config.json sysrepocfg -f json -d running -X
collect operational-config.json sysrepocfg -f json -d operational -X
# Sysrepo YANG modules
if command -v sysrepoctl >/dev/null 2>&1; then
collect sysrepo-modules.txt sysrepoctl -l
fi
# Startup config (may not exist on first boot)
if [ -f /cfg/startup-config.cfg ]; then
cp /cfg/startup-config.cfg "${COLLECT_DIR}/startup-config.cfg" 2>> "${EXEC_LOG}"
else
echo "No startup-config.cfg found" > "${COLLECT_DIR}/startup-config.cfg"
fi
# System logs and runtime data
if [ -d /var/log ]; then
if ls -A /var/log >/dev/null 2>&1; then
tar czf "${COLLECT_DIR}/logs.tar.gz" -C / var/log 2>> "${EXEC_LOG}" || \
echo "Failed to collect /var/log" > "${COLLECT_DIR}/logs.tar.gz.error"
else
echo "No logs in /var/log" > "${COLLECT_DIR}/logs.tar.gz.error"
fi
fi
# Collect crash dumps if they exist
if [ -d /var/crash ]; then
if ls -A /var/crash >/dev/null 2>&1; then
tar czf "${COLLECT_DIR}/crash.tar.gz" -C / var/crash 2>> "${EXEC_LOG}" || \
echo "Failed to collect /var/crash" > "${COLLECT_DIR}/crash.tar.gz.error"
else
echo "No crash dumps in /var/crash" > "${COLLECT_DIR}/crash.tar.gz.error"
fi
fi
# Tail /var/log/messages to capture live logging
if [ -f /var/log/messages ]; then
echo "Tailing /var/log/messages for ${LOG_TAIL_SEC} seconds (please wait)..." >&2
{
echo "=== Starting tail of /var/log/messages for ${LOG_TAIL_SEC} seconds ==="
timeout "${LOG_TAIL_SEC}" tail -F /var/log/messages 2>/dev/null || true
echo ""
echo "=== End of ${LOG_TAIL_SEC}-second tail ==="
} > "${COLLECT_DIR}/logs-tail-${LOG_TAIL_SEC}s.txt" 2>> "${EXEC_LOG}"
echo "Log tail complete." >&2
fi
if [ -d /run/net ]; then
if ls -A /run/net >/dev/null 2>&1; then
tar czf "${COLLECT_DIR}/run-net.tar.gz" -C / run/net 2>> "${EXEC_LOG}" || \
echo "Failed to collect /run/net" > "${COLLECT_DIR}/run-net.tar.gz.error"
else
echo "No data in /run/net" > "${COLLECT_DIR}/run-net.tar.gz.error"
fi
fi
if [ -d /run/finit ]; then
if ls -A /run/finit >/dev/null 2>&1; then
tar czf "${COLLECT_DIR}/run-finit.tar.gz" -C / run/finit 2>> "${EXEC_LOG}" || \
echo "Failed to collect /run/finit" > "${COLLECT_DIR}/run-finit.tar.gz.error"
else
echo "No data in /run/finit" > "${COLLECT_DIR}/run-finit.tar.gz.error"
fi
fi
# Kernel and system state
collect system/dmesg.txt ${SUDO} dmesg
collect system/free.txt free -h
collect system/stat.txt cat /proc/stat
collect system/softirqs.txt cat /proc/softirqs
collect system/locks.txt cat /proc/locks
collect system/meminfo.txt cat /proc/meminfo
collect system/file-nr.txt cat /proc/sys/fs/file-nr
collect system/ps.txt ps -o pid,rss,comm
collect system/df.txt df -h
# Finit/init state
if command -v initctl >/dev/null 2>&1; then
collect initctl/cgroup.txt initctl cgroup
collect initctl/status.txt initctl status
fi
# CPU statistics
if command -v mpstat >/dev/null 2>&1; then
collect system/mpstat.txt mpstat -P ALL 1 1
else
echo "mpstat not available" > "${COLLECT_DIR}/mpstat.txt"
fi
# Top output (two samples)
collect system/top.txt sh -c 'top -b -n 2 -d 2'
# Interrupt statistics (before and after 2 second delay)
collect system/interrupts1.txt cat /proc/interrupts
sleep 2
collect system/interrupts2.txt cat /proc/interrupts
# Network information
collect network/ip/addr.json ip -s -d -j addr show
collect network/ip/route.json ip -s -d -j route show
collect network/ip/link.json ip -s -d -j link show
collect network/ip/neigh.json ip -s -d -j neigh show
collect network/ifconfig.txt ifconfig -a
# Collect ethtool information for all ethernet interfaces
if command -v ethtool >/dev/null 2>&1; then
# Get list of ethernet interfaces (any interface with link/ether)
ip -o link show | grep 'link/ether' | awk -F': ' '{print $2}' > "${COLLECT_DIR}/.iface-list" 2>> "${EXEC_LOG}"
if [ -s "${COLLECT_DIR}/.iface-list" ]; then
while IFS= read -r iface; do
# ethtool typically needs root/sudo
collect "network/ethtool/${iface}.txt" ${SUDO} ethtool "$iface"
collect "network/ethtool/stats-${iface}.txt" ${SUDO} ethtool -S "$iface"
collect "network/ethtool/module-${iface}.txt" ${SUDO} ethtool -m "$iface"
done < "${COLLECT_DIR}/.iface-list"
fi
rm -f "${COLLECT_DIR}/.iface-list"
fi
if command -v bridge >/dev/null 2>&1; then
collect network/bridge/link.json bridge -d -s -j link show
collect network/bridge/fdb.json bridge -d -s -j fdb show
fi
# Firewall rules
if command -v nft >/dev/null 2>&1; then
collect network/nftables.txt ${SUDO} nft list ruleset
fi
# FRR routing information
if command -v vtysh >/dev/null 2>&1; then
collect frr/running-config.txt vtysh -c "show running-config"
collect frr/ip-route.txt vtysh -c "show ip route"
collect frr/ospf-interfaces.txt vtysh -c "show ip ospf interfaces"
collect frr/ospf-neighbor.txt vtysh -c "show ip ospf neighbor"
collect frr/ospf-routes.txt vtysh -c "show ip ospf routes"
collect frr/bgp-summary.txt vtysh -c "show ip bgp summary"
collect frr/bfd-peers.txt vtysh -c "show bfd peers"
fi
# Container information
if command -v podman >/dev/null 2>&1; then
# List all containers
collect podman/ps.txt podman ps -a
# Collect podman system info
collect podman/info.json podman info --format=json
# Get list of containers and inspect each
if podman ps -a --format '{{.Names}}' > "${COLLECT_DIR}/.container-list" 2>> "${EXEC_LOG}"; then
while IFS= read -r container; do
if [ -n "$container" ]; then
safe_name=$(echo "$container" | tr '/' '_')
collect "podman/inspect-${safe_name}.json" podman inspect "$container"
fi
done < "${COLLECT_DIR}/.container-list"
rm -f "${COLLECT_DIR}/.container-list"
fi
fi
# Additional system information
collect system/lsmod.txt lsmod
if command -v lspci >/dev/null 2>&1; then
collect system/lspci.txt lspci -v
else
echo "lspci not available" > "${COLLECT_DIR}/lspci.txt"
fi
if command -v lsusb >/dev/null 2>&1; then
collect system/lsusb.txt lsusb -v
else
echo "lsusb not available" > "${COLLECT_DIR}/lsusb.txt"
fi
# Disk I/O stats
if command -v iostat >/dev/null 2>&1; then
collect system/iostat.txt iostat -x 1 2
else
echo "iostat not available" > "${COLLECT_DIR}/iostat.txt"
fi
# Process resource usage
if command -v pstree >/dev/null 2>&1; then
collect system/pstree.txt pstree -p
else
collect system/pstree.txt ps fax
fi
# Environment and versions
collect system/env.txt env
# Network sockets
if command -v netstat >/dev/null 2>&1; then
collect system/netstat.txt netstat -tunlp
else
collect system/netstat.txt ss -tunlp
fi
# Completion timestamp in log
echo "" >> "${EXEC_LOG}"
echo "Completed: $(date -Iseconds)" >> "${EXEC_LOG}"
# Notify user that collection is done
echo "Collection complete. Creating archive..." >&2
# Create final tar.gz and output to stdout
# Use -C to change to parent directory so paths in archive don't include full path
cd "${WORK_DIR}"
# Check if password encryption is requested
if [ -n "$PASSWORD" ]; then
if ! command -v gpg >/dev/null 2>&1; then
echo "Error: --password specified but gpg is not available" >&2
exit 1
fi
echo "Encrypting with GPG..." >&2
tar czf - "$(basename "${COLLECT_DIR}")" 2>> "${EXEC_LOG}" | \
gpg --batch --yes --passphrase "$PASSWORD" --pinentry-mode loopback -c 2>> "${EXEC_LOG}"
echo "" >&2
echo "WARNING: Remember to share the encryption password out-of-band!" >&2
echo " Do not send it in the same email as the encrypted file." >&2
else
tar czf - "$(basename "${COLLECT_DIR}")" 2>> "${EXEC_LOG}"
fi
}
cmd_clean()
{
dry_run=0
days=7
# Parse options
while [ $# -gt 0 ]; do
case "$1" in
--dry-run|-n)
dry_run=1
shift
;;
--days|-d)
if [ -z "$2" ] || [ "$2" -le 0 ] 2>/dev/null; then
echo "Error: --days requires a positive number" >&2
exit 1
fi
days="$2"
shift 2
;;
*)
echo "Error: Unknown option '$1'" >&2
echo "Usage: support clean [--dry-run] [--days N]" >&2
exit 1
;;
esac
done
if [ "$dry_run" -eq 1 ]; then
echo "Dry run - no files will be deleted"
echo ""
fi
echo "Looking for support directories older than ${days} days..."
echo ""
# If WORK_DIR is set globally, only search there
# Otherwise search in both /var/lib/support and $HOME
if [ -n "$WORK_DIR" ]; then
search_dirs="$WORK_DIR"
else
search_dirs="/var/lib/support ${HOME}"
fi
total_count=0
for search_dir in $search_dirs; do
if [ ! -d "$search_dir" ]; then
continue
fi
# Use find to locate old support directories
# The pattern matches: support-YYYY-MM-DD* format
find "$search_dir" -maxdepth 1 -type d -name "support-*-20*" -mtime "+${days}" 2>/dev/null | while IFS= read -r dir; do
size=$(du -sh "$dir" 2>/dev/null | awk '{print $1}')
mtime=$(stat -c %y "$dir" 2>/dev/null | cut -d' ' -f1)
if [ "$dry_run" -eq 1 ]; then
echo "Would remove: $dir ($size, modified: $mtime)"
else
echo "Removing: $dir ($size, modified: $mtime)"
rm -rf "$dir"
fi
done
# Count directories found in this location
count=$(find "$search_dir" -maxdepth 1 -type d -name "support-*-20*" -mtime "+${days}" 2>/dev/null | wc -l)
total_count=$((total_count + count))
done
echo ""
if [ "$total_count" -eq 0 ]; then
echo "No support directories older than ${days} days found in /var/lib/support or home directory."
elif [ "$dry_run" -eq 1 ]; then
echo "Found ${total_count} directories. Run without --dry-run to remove them."
else
echo "Removed ${total_count} directories."
fi
}
usage()
{
echo "Usage: support [global-options] <command> [options]"
echo ""
echo "Global options:"
echo " -w, --work-dir PATH Use PATH as working directory for collection/cleanup"
echo " (default: /var/lib/support with fallback to \$HOME)"
echo ""
echo "Commands:"
echo " collect [options] Collect system information for support/troubleshooting"
echo " NOTE: may take up to a minute to finish, please wait!"
echo " clean [options] Remove old support collection directories"
echo ""
echo "Options for collect:"
echo " -s, --log-sec SEC Tail /var/log/messages for SEC seconds (default: 30)"
echo " -p, --password [PASS] Encrypt output with GPG. If PASS is omitted, prompts"
echo " interactively or reads from stdin, so possible to do"
echo " echo "\$MYSECRET" | ... (recommended for security)"
echo ""
echo "Options for clean:"
echo " -n, --dry-run Show what would be deleted without deleting"
echo " -d, --days N Remove directories older than N days (default: 7)"
echo ""
echo "Examples:"
echo " support collect > support-data.tar.gz"
echo " support collect -p > support-data.tar.gz.gpg"
echo " support collect --password mypass > support-data.tar.gz.gpg"
echo " support --work-dir /tmp/ram collect > support-data.tar.gz"
echo " ssh user@device support collect > support-data.tar.gz"
echo " support clean --dry-run"
echo " support clean --days 30"
echo " support --work-dir /tmp/ram clean"
exit 1
}
# Main command dispatcher
# Parse global options first
WORK_DIR=""
while [ $# -gt 0 ]; do
case "$1" in
-w|--work-dir)
if [ -z "$2" ]; then
echo "Error: --work-dir requires a path argument" >&2
exit 1
fi
WORK_DIR="$2"
shift 2
;;
-*)
# Unknown option - might be a command-specific option
break
;;
*)
# Not an option, must be the command
break
;;
esac
done
if [ $# -lt 1 ]; then
usage
fi
command="$1"
shift
case "$command" in
collect)
cmd_collect "$@"
;;
clean)
cmd_clean "$@"
;;
help|--help|-h)
usage
;;
*)
echo "Error: Unknown command '$command'" >&2
echo "" >&2
usage
;;
esac
+3 -4
View File
@@ -4,10 +4,9 @@ ifdef::topdoc[:imagesdir: {topdoc}../../test/case/containers/host_commands]
==== Description
This test verifies that a container in privileged mode can break out of
the container to execute commands that affect the host system.
Specifically, it confirms that the container can change the hostname of
the host.
This test verifies that a container running on Infix can execute commands
that affect the host system. Specifically, it confirms that the container
can change the hostname of the host.
==== Topology
+1 -1
View File
@@ -1,6 +1,6 @@
=== DHCPv6 Basic
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_dhcp/client6_basic]
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/dhcp/client6_basic]
==== Description
@@ -1,6 +1,6 @@
=== DHCPv6 Prefix Delegation
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_dhcp/client6_prefix_delegation]
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/dhcp/client6_prefix_delegation]
==== Description
+9
View File
@@ -1,4 +1,13 @@
:testgroup:
== Miscellaneous Tests
Tests verifying system utilities and operational features:
- Operational datastore query for all configuration
- Support data collection with work-dir and GPG encryption
include::operational_all/Readme.adoc[]
<<<
include::support_collect/Readme.adoc[]
+3
View File
@@ -2,5 +2,8 @@
- name: Get operational
case: operational_all/test.py
- name: Support data collection
case: support_collect/test.py
#- name: start_from_startup
# case: start_from_startup.py
+1
View File
@@ -0,0 +1 @@
test.adoc
+24
View File
@@ -0,0 +1,24 @@
=== Support data collection
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/misc/support_collect]
==== Description
Verify that the support collect command works and produces a valid tarball
with expected content. Tests both the --work-dir global option and GPG
encryption (when available on target).
==== Topology
image::topology.svg[Support data collection topology, align=center, scaledwidth=75%]
==== Sequence
. Set up topology and attach to target DUT
. Check for GPG availability on target
. Run support collect with --work-dir and short log tail
. Verify tarball was created and is valid
. Run support collect with GPG encryption
. Verify encrypted file and decrypt it
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Support data collection
Verify that the support collect command works and produces a valid tarball
with expected content. Tests both the --work-dir global option and GPG
encryption (when available on target).
"""
import os
import subprocess
import tarfile
import tempfile
import infamy
import infamy.ssh as ssh
with infamy.Test() as test:
with test.step("Set up topology and attach to target DUT"):
env = infamy.Env()
target = env.attach("target", "mgmt")
tgtssh = env.attach("target", "mgmt", "ssh")
with test.step("Check for GPG availability on target"):
result = tgtssh.run("command -v gpg >/dev/null 2>&1", check=False)
has_gpg = (result.returncode == 0)
if has_gpg:
print("GPG is available on target - will test encryption")
else:
print("GPG not available on target - skipping encryption tests")
with test.step("Run support collect with --work-dir and short log tail"):
# Create temporary file for output
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
output_file = tmp.name
# Use /tmp as work-dir to test the --work-dir option
# Run support collect via SSH with short log tail for testing
# Capture stdout (the tarball) to file
# Note: timeout is generous to handle systems with many network ports
# (ethtool collection scales with number of interfaces)
with open(output_file, 'wb') as f:
result = tgtssh.run("support --work-dir /tmp collect --log-sec 2",
stdout=f,
stderr=subprocess.PIPE,
timeout=120)
if result.returncode != 0:
stderr_output = result.stderr.decode('utf-8') if result.stderr else ""
print(f"support collect failed with return code {result.returncode}")
print(f"stderr: {stderr_output}")
raise Exception("support collect command failed")
with test.step("Verify tarball was created and is valid"):
if not os.path.exists(output_file):
raise Exception(f"Output file {output_file} was not created")
file_size = os.path.getsize(output_file)
if file_size == 0:
raise Exception("Output tarball is empty")
print(f"Tarball created: {file_size} bytes")
# Verify it's a valid tar.gz
try:
with tarfile.open(output_file, 'r:gz') as tar:
members = tar.getnames()
print(f"Tarball contains {len(members)} files/directories")
# Verify some expected files exist
expected_files = [
'collection.log',
'operational-config.json',
'system/dmesg.txt',
'system/meminfo.txt',
'network/ip/addr.json'
]
root_dir = members[0] if members else None
for expected in expected_files:
full_path = f"{root_dir}/{expected}" if root_dir else expected
if full_path not in members:
print(f"Warning: Expected file '{expected}' not found in tarball")
else:
print(f"Found: {expected}")
except tarfile.TarError as e:
raise Exception(f"Invalid tarball: {e}")
finally:
# Clean up
if os.path.exists(output_file):
os.remove(output_file)
if has_gpg:
with test.step("Run support collect with GPG encryption"):
# Create temporary file for encrypted output
with tempfile.NamedTemporaryFile(suffix=".tar.gz.gpg", delete=False) as tmp:
encrypted_file = tmp.name
# Use a test password
test_password = "test-support-password-123"
# Run support collect with encryption
with open(encrypted_file, 'wb') as f:
result = tgtssh.run(f"support --work-dir /tmp collect --log-sec 2 --password {test_password}",
stdout=f,
stderr=subprocess.PIPE,
timeout=120)
if result.returncode != 0:
stderr_output = result.stderr.decode('utf-8') if result.stderr else ""
print(f"support collect with encryption failed: {stderr_output}")
raise Exception("support collect with --password failed")
with test.step("Verify encrypted file and decrypt it"):
if not os.path.exists(encrypted_file):
raise Exception(f"Encrypted output file {encrypted_file} was not created")
file_size = os.path.getsize(encrypted_file)
if file_size == 0:
raise Exception("Encrypted output file is empty")
print(f"Encrypted file created: {file_size} bytes")
# Create temporary file for decrypted output
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
decrypted_file = tmp.name
try:
# Decrypt the file using gpg
with open(encrypted_file, 'rb') as ef:
with open(decrypted_file, 'wb') as df:
decrypt_result = subprocess.run(
["gpg", "--batch", "--yes", "--passphrase", test_password,
"--pinentry-mode", "loopback", "-d"],
stdin=ef,
stdout=df,
stderr=subprocess.PIPE,
timeout=30
)
if decrypt_result.returncode != 0:
stderr_output = decrypt_result.stderr.decode('utf-8') if decrypt_result.stderr else ""
print(f"GPG decryption failed: {stderr_output}")
raise Exception("Failed to decrypt GPG-encrypted support data")
print("Successfully decrypted GPG file")
# Verify the decrypted file is a valid tarball
with tarfile.open(decrypted_file, 'r:gz') as tar:
members = tar.getnames()
print(f"Decrypted tarball contains {len(members)} files/directories")
# Verify some expected files exist
expected_files = [
'collection.log',
'operational-config.json',
'system/dmesg.txt'
]
root_dir = members[0] if members else None
for expected in expected_files:
full_path = f"{root_dir}/{expected}" if root_dir else expected
if full_path not in members:
print(f"Warning: Expected file '{expected}' not found in decrypted tarball")
else:
print(f"Found in decrypted tarball: {expected}")
except tarfile.TarError as e:
raise Exception(f"Decrypted file is not a valid tarball: {e}")
except subprocess.TimeoutExpired:
raise Exception("GPG decryption timed out")
except FileNotFoundError:
print("Warning: gpg not available on host system - skipping decryption verification")
finally:
# Clean up
if os.path.exists(encrypted_file):
os.remove(encrypted_file)
if os.path.exists(decrypted_file):
os.remove(decrypted_file)
test.succeed()
+1
View File
@@ -0,0 +1 @@
../../../infamy/topologies/1x1.dot
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: 1x1 Pages: 1 -->
<svg width="424pt" height="45pt"
viewBox="0.00 0.00 424.03 45.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 41)">
<title>1x1</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-41 420.03,-41 420.03,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="black" points="0,-0.5 0,-36.5 100,-36.5 100,-0.5 0,-0.5"/>
<text text-anchor="middle" x="25" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-0.5 50,-36.5 "/>
<text text-anchor="middle" x="75" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
</g>
<!-- target -->
<g id="node2" class="node">
<title>target</title>
<polygon fill="none" stroke="black" points="300.03,-0.5 300.03,-36.5 416.03,-36.5 416.03,-0.5 300.03,-0.5"/>
<text text-anchor="middle" x="325.03" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="350.03,-0.5 350.03,-36.5 "/>
<text text-anchor="middle" x="383.03" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">target</text>
</g>
<!-- host&#45;&#45;target -->
<g id="edge1" class="edge">
<title>host:mgmt&#45;&#45;target:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M100,-18.5C100,-18.5 300.03,-18.5 300.03,-18.5"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -16,7 +16,13 @@ image::topology.svg[Syslog Hostname Filtering topology, align=center, scaledwidt
. Set up topology and attach to DUTs
. Clean up old log files on server
. Configure server as syslog sink with hostname filtering
. Wait for server interface to be operational
. Verify server IP address is configured
. Verify syslog server is listening on UDP port 514
. Configure client to forward logs to server
. Wait for client interface to be operational
. Verify client IP address is configured
. Verify network connectivity between client and server
. Send log messages with different hostnames
. Verify router1 log contains only router1 messages
. Verify router2 log contains only router2 messages
Executable
+442
View File
@@ -0,0 +1,442 @@
#!/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 <directory> # Single device TUI browser
support analyze <dir1> <dir2> # Comparison view (auto-detect)
support diff <dir1> <dir2> [--file] # Focused diff view
support summary <directory...> # 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-<hostname>-<timestamp>
# 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> [directory...]", file=sys.stderr)
print(" or: support <command> [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())
+992
View File
@@ -0,0 +1,992 @@
"""
Textual TUI components for support data analysis
"""
from pathlib import Path
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
from textual.widgets import Header, Footer, Static, Label, Tree, DataTable, RichLog, Select, Input
from textual.widgets.tree import TreeNode
from textual.binding import Binding
from textual.screen import Screen
from textual.reactive import reactive
from textual.message import Message
from rich.text import Text
from rich.table import Table as RichTable
from typing import Optional
import re
class FileContentViewer(RichLog):
"""Widget to display file contents with proper text selection support."""
BINDINGS = [
Binding("/", "start_search", "Search", show=True),
Binding("n", "next_match", "Next", show=False),
Binding("N", "prev_match", "Prev", show=False),
Binding("ctrl+l", "toggle_line_numbers", "Line #", show=True),
]
class StartSearch(Message):
"""Message sent when search is requested."""
def __init__(self, viewer):
super().__init__()
self.viewer = viewer
class ToggleLineNumbers(Message):
"""Message sent when line numbers toggle is requested."""
def __init__(self, viewer):
super().__init__()
self.viewer = viewer
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs, wrap=False, highlight=True, markup=False)
self.border_title = "File Content"
self.current_file = None
self.linked_viewer = None # For scroll synchronization
self.scroll_locked = False
self._syncing_scroll = False # Prevent circular scroll updates
self._file_lines = [] # Store original lines
self._search_pattern = None
self._search_matches = [] # List of line numbers with matches
self._current_match_index = -1
self._show_line_numbers = False
def load_file(self, file_path: Path):
"""Load and display a file."""
self.current_file = file_path
self.border_title = f"File: {file_path.name}"
try:
content = file_path.read_text()
self._file_lines = content.splitlines()
self._refresh_display()
except Exception as e:
self.clear()
self.write(f"Error reading file: {e}", style="bold red")
self._file_lines = []
def _refresh_display(self):
"""Refresh the display with current settings (line numbers, search highlights)."""
self.clear()
for line_num, line in enumerate(self._file_lines, 1):
display_line = line
# Add line numbers if enabled
if self._show_line_numbers:
line_prefix = f"{line_num:4d} | "
display_line = line_prefix + line
# Highlight search matches
if self._search_pattern and self._search_pattern.lower() in line.lower():
# Simple highlight - wrap matches in style tags
highlighted = self._highlight_matches(display_line, self._search_pattern)
self.write(Text.from_markup(highlighted))
else:
self.write(display_line)
def _highlight_matches(self, text: str, pattern: str) -> str:
"""Highlight search pattern in text (case insensitive)."""
if not pattern:
return text
# Use regex for case-insensitive replacement
def replacer(match):
return f"[black on yellow]{match.group(0)}[/black on yellow]"
try:
highlighted = re.sub(re.escape(pattern), replacer, text, flags=re.IGNORECASE)
return highlighted
except:
return text
def search(self, pattern: str):
"""Search for pattern in file and highlight matches."""
self._search_pattern = pattern
self._search_matches = []
self._current_match_index = -1
if pattern:
# Find all lines with matches
for line_num, line in enumerate(self._file_lines, 1):
if pattern.lower() in line.lower():
self._search_matches.append(line_num)
if self._search_matches:
self._current_match_index = 0
# Update title with match count
self.border_title = f"File: {self.current_file.name} - {len(self._search_matches)} matches"
# Jump to first match
self._jump_to_current_match()
else:
self.border_title = f"File: {self.current_file.name} - No matches"
self._refresh_display()
def _jump_to_current_match(self):
"""Scroll to the current match."""
if self._current_match_index >= 0 and self._current_match_index < len(self._search_matches):
line_num = self._search_matches[self._current_match_index]
# Scroll to make the line visible (approximate)
# RichLog doesn't have direct line-based scrolling, so we estimate
total_lines = len(self._file_lines)
if total_lines > 0:
scroll_ratio = (line_num - 1) / total_lines
# This is approximate - adjust max scroll as needed
self.scroll_y = scroll_ratio * self.max_scroll_y if self.max_scroll_y > 0 else 0
def action_next_match(self):
"""Jump to next search match."""
if self._search_matches:
self._current_match_index = (self._current_match_index + 1) % len(self._search_matches)
self._jump_to_current_match()
# Update title to show current match position
current = self._current_match_index + 1
total = len(self._search_matches)
self.border_title = f"File: {self.current_file.name} - Match {current}/{total}"
def action_prev_match(self):
"""Jump to previous search match."""
if self._search_matches:
self._current_match_index = (self._current_match_index - 1) % len(self._search_matches)
self._jump_to_current_match()
# Update title to show current match position
current = self._current_match_index + 1
total = len(self._search_matches)
self.border_title = f"File: {self.current_file.name} - Match {current}/{total}"
def action_toggle_line_numbers(self):
"""Toggle line numbers display - post message for parent to handle."""
# Post message to parent so it can coordinate between panes
self.post_message(self.ToggleLineNumbers(self))
def toggle_line_numbers_internal(self):
"""Actually toggle line numbers (called by parent)."""
self._show_line_numbers = not self._show_line_numbers
self._refresh_display()
return self._show_line_numbers
def action_start_search(self):
"""Start search - post a message that parent can handle."""
# Post a message to notify parent
self.post_message(self.StartSearch(self))
def clear_content(self):
"""Clear the viewer."""
self.clear()
self.border_title = "File Content"
self.current_file = None
self._file_lines = []
self._search_pattern = None
self._search_matches = []
def on_mount(self) -> None:
"""Set up scroll watching."""
self.watch(self, "scroll_y", self._on_scroll_change)
def _on_scroll_change(self, old_y: float, new_y: float) -> None:
"""Handle scroll position changes."""
if self._syncing_scroll or not self.scroll_locked or not self.linked_viewer:
return
# Sync scroll to linked viewer
if self.linked_viewer and new_y != old_y:
self.linked_viewer._syncing_scroll = True
self.linked_viewer.scroll_y = new_y
self.linked_viewer._syncing_scroll = False
class SupportTree(Tree):
"""Custom tree widget for navigating support archive structure."""
def __init__(self, archive, *args, **kwargs):
super().__init__("Support Data", *args, **kwargs)
self.archive = archive
self.show_root = True
self.guide_depth = 4
# Build tree structure
self._build_tree()
def _build_tree(self):
"""Build the tree structure from archive."""
structure = self.archive.get_structure()
# Add categories as top-level nodes
for category, files in sorted(structure.items()):
if not files:
continue
category_node = self.root.add(f"📁 {category}", expand=True)
category_node.data = {"type": "category", "name": category}
# Group files by subdirectory - keep full paths
file_tree = {}
for file_path in sorted(files):
parts = list(file_path.parts)
if parts[0] == category:
parts = parts[1:] # Remove category prefix
# Build nested structure, storing full Path objects
current = file_tree
for i, part in enumerate(parts):
if i == len(parts) - 1:
# It's a file - store the full path
if part not in current:
current[part] = file_path # Store full relative path
else:
# It's a directory
if part not in current:
current[part] = {}
elif not isinstance(current[part], dict):
# Edge case: name collision
current[part] = {}
current = current[part]
# Add to tree
self._add_tree_nodes(category_node, file_tree)
def _add_tree_nodes(self, parent_node: TreeNode, tree_dict: dict):
"""Recursively add nodes to the tree."""
for name, subtree in sorted(tree_dict.items()):
if isinstance(subtree, Path):
# It's a file - use the stored full path
full_path = self.archive.path / subtree
node = parent_node.add_leaf(f"📄 {name}")
node.data = {"type": "file", "path": full_path}
elif isinstance(subtree, dict):
# It's a directory
dir_node = parent_node.add(f"📁 {name}", expand=False)
dir_node.data = {"type": "directory", "name": name}
self._add_tree_nodes(dir_node, subtree)
class SummaryView(Container):
"""View displaying summary of all archives with interactive selection."""
BINDINGS = [
Binding("space", "toggle_selection", "Select/Deselect", show=True),
Binding("enter", "confirm_selection", "Confirm", show=True),
]
def __init__(self, archives, *args, **kwargs):
super().__init__(*args, **kwargs)
self.archives = archives
self.selected_indices = set() # Track selected archive indices
self.border_title = "Summary - Select archive(s) to analyze"
def compose(self) -> ComposeResult:
"""Create the summary layout."""
# Instructions
yield Static("Use Space to select/deselect archives, Enter to confirm (1 for analyze, 2 for compare)",
classes="summary-instructions")
# Interactive table
table = DataTable(id="archive-table", cursor_type="row")
table.zebra_stripes = True
yield table
def on_mount(self) -> None:
"""Populate the table when mounted."""
table = self.query_one("#archive-table", DataTable)
# Add columns
table.add_column("", key="selected", width=3) # Selection indicator
table.add_column("Hostname", key="hostname")
table.add_column("Timestamp", key="timestamp")
table.add_column("Uptime", key="uptime")
table.add_column("Load", key="load")
table.add_column("Memory", key="memory")
table.add_column("Issues", key="issues")
# Add rows
for idx, archive in enumerate(self.archives):
data = archive.get_summary_data()
# Format data
uptime_str = self._format_uptime(data["uptime_seconds"])
mem_pct = data["memory_percent"]
mem_str = f"{data['memory_used_mb']}M/{data['memory_total_mb']}M ({mem_pct}%)"
# Format issues
errors = data["dmesg_errors"]
warnings = data["dmesg_warnings"]
if errors > 0:
issues_str = f"{errors} err"
if warnings > 0:
issues_str += f", {warnings} warn"
elif warnings > 0:
issues_str = f"{warnings} warn"
else:
issues_str = "none"
table.add_row(
"", # Selection indicator (empty initially)
archive.hostname,
archive.timestamp or "unknown",
uptime_str,
data["load_avg"],
mem_str,
issues_str,
key=str(idx)
)
# Focus the table
table.focus()
def action_toggle_selection(self) -> None:
"""Toggle selection of current row."""
table = self.query_one("#archive-table", DataTable)
if table.cursor_row is None:
return
# Get the cursor position (row index)
cursor_row_idx = table.cursor_row
# Get all row keys and find the one at cursor position
row_keys = list(table.rows.keys())
if cursor_row_idx >= len(row_keys):
return
row_key = row_keys[cursor_row_idx]
idx = int(row_key.value)
# Toggle selection
if idx in self.selected_indices:
self.selected_indices.remove(idx)
# Update the row to remove checkmark
table.update_cell(row_key, "selected", "")
else:
self.selected_indices.add(idx)
# Update the row to add checkmark
table.update_cell(row_key, "selected", "")
# Update border title with selection count
count = len(self.selected_indices)
if count == 0:
self.border_title = "Summary - Select archive(s) to analyze"
elif count == 1:
self.border_title = f"Summary - {count} archive selected (analyze)"
elif count == 2:
self.border_title = f"Summary - {count} archives selected (compare)"
else:
self.border_title = f"Summary - {count} archives selected (too many for compare)"
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle Enter key on a row - same as confirm selection."""
self.action_confirm_selection()
def action_confirm_selection(self) -> None:
"""Confirm selection and switch to appropriate view."""
if len(self.selected_indices) == 0:
self.app.notify("No archives selected. Use Space to select.", severity="warning", timeout=2)
return
elif len(self.selected_indices) == 1:
# Single archive - analyze mode
idx = list(self.selected_indices)[0]
self.app.selected_archives = [self.archives[idx]]
self.app.current_mode = "analyze"
elif len(self.selected_indices) == 2:
# Two archives - compare mode
indices = sorted(self.selected_indices)
self.app.selected_archives = [self.archives[indices[0]], self.archives[indices[1]]]
# Debug: show which archives are selected
arch1 = self.archives[indices[0]].hostname
arch2 = self.archives[indices[1]].hostname
self.app.notify(f"Comparing: {arch1} vs {arch2}", timeout=2)
self.app.current_mode = "compare"
else:
self.app.notify(f"Selected {len(self.selected_indices)} archives. Select 1 for analyze or 2 for compare.",
severity="warning", timeout=3)
def _format_uptime(self, seconds: int) -> str:
"""Format uptime seconds into human-readable string."""
if seconds < 0: # Only negative indicates truly unknown
return "unknown"
days = seconds // 86400
hours = (seconds % 86400) // 3600
minutes = (seconds % 3600) // 60
if days > 0:
return f"{days}d {hours}h"
elif hours > 0:
return f"{hours}h {minutes}m"
else:
return f"{minutes}m"
class ComparisonPane(Container):
"""Single pane in comparison view with file selector and viewer."""
def __init__(self, archive, pane_id: str, *args, **kwargs):
super().__init__(*args, **kwargs)
self.archive = archive
self.pane_id = pane_id
self.border_title = f"{archive.hostname} @ {archive.timestamp or 'unknown'}"
self.current_file = None
self._search_visible = False
def compose(self) -> ComposeResult:
"""Create the pane layout."""
# Search input (initially hidden)
search_input = Input(placeholder="Search (press Enter)", id=f"search-input-{self.pane_id}")
search_input.display = False
yield search_input
# File selector dropdown
yield Static("Select file:", classes="file-selector-label")
yield Select(
options=self._get_file_options(),
prompt="Choose a file...",
id=f"file-select-{self.pane_id}",
classes="file-selector"
)
# File viewer
viewer = FileContentViewer(id=f"viewer-{self.pane_id}")
viewer.border_title = "File Content"
yield viewer
def _get_file_options(self):
"""Get list of all files in archive as select options."""
options = []
structure = self.archive.get_structure()
for category in sorted(structure.keys()):
files = structure[category]
for file_path in sorted(files):
# Create a readable label with category prefix
label = str(file_path)
value = str(file_path)
options.append((label, value))
return options
def on_select_changed(self, event: Select.Changed) -> None:
"""Handle file selection change."""
if event.select.id == f"file-select-{self.pane_id}" and event.value != Select.BLANK:
file_path = Path(event.value)
full_path = self.archive.path / file_path
viewer = self.query_one(f"#viewer-{self.pane_id}", FileContentViewer)
viewer.load_file(full_path)
self.current_file = file_path
def on_file_content_viewer_start_search(self, message: FileContentViewer.StartSearch) -> None:
"""Handle search request from viewer."""
self.action_start_search()
def load_file(self, relative_path: Path) -> bool:
"""Load a specific file by relative path. Returns True if successful."""
full_path = self.archive.path / relative_path
if full_path.exists():
viewer = self.query_one(f"#viewer-{self.pane_id}", FileContentViewer)
viewer.load_file(full_path)
self.current_file = relative_path
# Update selector
selector = self.query_one(f"#file-select-{self.pane_id}", Select)
selector.value = str(relative_path)
return True
return False
def action_start_search(self) -> None:
"""Show search input for this pane."""
search_input = self.query_one(f"#search-input-{self.pane_id}", Input)
search_input.display = True
self._search_visible = True
search_input.focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle search submission."""
if event.input.id == f"search-input-{self.pane_id}":
pattern = event.value
viewer = self.query_one(f"#viewer-{self.pane_id}", FileContentViewer)
viewer.search(pattern)
# Hide search input and refocus viewer
search_input = self.query_one(f"#search-input-{self.pane_id}", Input)
search_input.display = False
self._search_visible = False
viewer.focus()
# Notify parent to sync search to other pane
self.post_message(self.SearchSubmitted(self.pane_id, pattern))
class SearchSubmitted(Message):
"""Message sent when search is submitted in a pane."""
def __init__(self, pane_id: str, pattern: str):
super().__init__()
self.pane_id = pane_id
self.pattern = pattern
def on_key(self, event) -> None:
"""Handle escape key to close search input."""
if event.key == "escape" and self._search_visible:
search_input = self.query_one(f"#search-input-{self.pane_id}", Input)
search_input.display = False
self._search_visible = False
viewer = self.query_one(f"#viewer-{self.pane_id}", FileContentViewer)
viewer.focus()
event.stop()
event.prevent_default()
class ComparisonView(Container):
"""View for comparing two archives side-by-side."""
scroll_locked = reactive(False)
def __init__(self, archive1, archive2, *args, **kwargs):
super().__init__(*args, **kwargs)
self.archive1 = archive1
self.archive2 = archive2
self._syncing = False # Prevent circular updates
def compose(self) -> ComposeResult:
"""Create the comparison layout."""
with Horizontal():
# Left pane
yield ComparisonPane(self.archive1, "left", id="pane-left", classes="comparison-pane")
# Right pane
yield ComparisonPane(self.archive2, "right", id="pane-right", classes="comparison-pane")
def on_mount(self) -> None:
"""Try to load operational-config.json or a common file."""
# Preference: operational-config.json
preferred = Path("operational-config.json")
left_pane = self.query_one("#pane-left", ComparisonPane)
right_pane = self.query_one("#pane-right", ComparisonPane)
# Try preferred file first
left_loaded = left_pane.load_file(preferred)
right_loaded = right_pane.load_file(preferred)
# If preferred didn't work, find any common file
if not (left_loaded and right_loaded):
structure1 = self.archive1.get_structure()
structure2 = self.archive2.get_structure()
# Flatten file lists
files1 = set()
for files in structure1.values():
files1.update(str(f) for f in files)
files2 = set()
for files in structure2.values():
files2.update(str(f) for f in files)
# Find common files
common = files1 & files2
if common:
# Load the first common file (sorted for consistency)
first_common = Path(sorted(common)[0])
left_pane.load_file(first_common)
right_pane.load_file(first_common)
# Set up scroll event watchers
self._setup_scroll_sync()
# Notify that scroll is locked by default
self.app.notify("Scroll locked (press 'l' to unlock)", timeout=2)
# Set focus to left viewer so keys work immediately
try:
left_viewer = self.query_one("#viewer-left", FileContentViewer)
left_viewer.focus()
except:
pass
def _setup_scroll_sync(self) -> None:
"""Set up scroll synchronization between panes."""
try:
left_viewer = self.query_one("#viewer-left", FileContentViewer)
right_viewer = self.query_one("#viewer-right", FileContentViewer)
# Link viewers for scroll sync
left_viewer.linked_viewer = right_viewer
right_viewer.linked_viewer = left_viewer
# Enable scroll lock by default in compare mode
self.scroll_locked = True
left_viewer.scroll_locked = True
right_viewer.scroll_locked = True
# Store references
self._left_viewer = left_viewer
self._right_viewer = right_viewer
except:
pass
def on_select_changed(self, event: Select.Changed) -> None:
"""Handle file selection - sync to other pane if same file exists."""
if self._syncing:
return
if event.select.id == "file-select-left" and event.value != Select.BLANK:
# Left pane changed, try to load same file in right
file_path = Path(event.value)
right_pane = self.query_one("#pane-right", ComparisonPane)
self._syncing = True
right_pane.load_file(file_path) # Will fail gracefully if doesn't exist
self._syncing = False
elif event.select.id == "file-select-right" and event.value != Select.BLANK:
# Right pane changed, try to load same file in left
file_path = Path(event.value)
left_pane = self.query_one("#pane-left", ComparisonPane)
self._syncing = True
left_pane.load_file(file_path) # Will fail gracefully if doesn't exist
self._syncing = False
def action_toggle_lock(self) -> None:
"""Toggle scroll lock between panes."""
self.scroll_locked = not self.scroll_locked
# Propagate lock state to viewers
if hasattr(self, '_left_viewer') and hasattr(self, '_right_viewer'):
self._left_viewer.scroll_locked = self.scroll_locked
self._right_viewer.scroll_locked = self.scroll_locked
# Sync current scroll positions when locking
if self.scroll_locked:
# Sync right to left's position
self._right_viewer._syncing_scroll = True
self._right_viewer.scroll_y = self._left_viewer.scroll_y
self._right_viewer._syncing_scroll = False
status = "locked" if self.scroll_locked else "unlocked"
self.app.notify(f"Scroll {status}", timeout=1)
def on_file_content_viewer_toggle_line_numbers(self, message: FileContentViewer.ToggleLineNumbers) -> None:
"""Handle line numbers toggle from either viewer - sync both."""
if hasattr(self, '_left_viewer') and hasattr(self, '_right_viewer'):
# Toggle both viewers
new_state = self._left_viewer.toggle_line_numbers_internal()
self._right_viewer._show_line_numbers = new_state
self._right_viewer._refresh_display()
status = "on" if new_state else "off"
self.app.notify(f"Line numbers {status}", timeout=1)
def on_comparison_pane_search_submitted(self, message) -> None:
"""Handle search submitted in one pane - sync to other pane."""
# Apply the search to the other pane
if message.pane_id == "left":
# Search was in left, apply to right
right_viewer = self.query_one("#viewer-right", FileContentViewer)
right_viewer.search(message.pattern)
else:
# Search was in right, apply to left
left_viewer = self.query_one("#viewer-left", FileContentViewer)
left_viewer.search(message.pattern)
def action_start_search(self) -> None:
"""Start search in the focused pane."""
# Find which pane has focus and trigger search there
try:
left_pane = self.query_one("#pane-left", ComparisonPane)
right_pane = self.query_one("#pane-right", ComparisonPane)
# Check which viewer is focused
left_viewer = self.query_one("#viewer-left", FileContentViewer)
right_viewer = self.query_one("#viewer-right", FileContentViewer)
if left_viewer.has_focus or left_viewer.has_focus_within:
left_pane.action_start_search()
else:
# Default to right or trigger on whichever had focus last
right_pane.action_start_search()
except:
pass
class SingleArchiveView(Container):
"""View for browsing a single support archive."""
def __init__(self, archive, *args, **kwargs):
super().__init__(*args, **kwargs)
self.archive = archive
self._search_visible = False
def compose(self) -> ComposeResult:
"""Create the layout."""
# Search input (initially hidden)
search_input = Input(placeholder="Search (press Enter)", id="search-input")
search_input.display = False
yield search_input
with Horizontal():
# Left pane: File tree
tree = SupportTree(self.archive, id="file-tree")
tree.border_title = f"Archive: {self.archive.hostname}"
yield tree
# Right pane: File viewer
yield FileContentViewer(id="file-viewer")
def on_mount(self) -> None:
"""Set focus and pre-load operational-config.json if available."""
# Try to pre-load operational-config.json
preferred = Path("operational-config.json")
full_path = self.archive.path / preferred
viewer = self.query_one("#file-viewer", FileContentViewer)
if full_path.exists():
viewer.load_file(full_path)
# Focus the viewer since we have content to show
viewer.focus()
else:
# No preferred file, focus tree for navigation
tree = self.query_one("#file-tree", SupportTree)
tree.focus()
def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
"""Handle file selection in tree."""
node = event.node
if node.data and node.data.get("type") == "file":
file_path = node.data["path"]
viewer = self.query_one("#file-viewer", FileContentViewer)
viewer.load_file(file_path)
def on_file_content_viewer_start_search(self, message: FileContentViewer.StartSearch) -> None:
"""Handle search request from viewer."""
self.action_start_search()
def on_file_content_viewer_toggle_line_numbers(self, message: FileContentViewer.ToggleLineNumbers) -> None:
"""Handle line numbers toggle from viewer."""
# In single view, just toggle the viewer and notify
viewer = self.query_one("#file-viewer", FileContentViewer)
new_state = viewer.toggle_line_numbers_internal()
status = "on" if new_state else "off"
self.app.notify(f"Line numbers {status}", timeout=1)
def action_start_search(self) -> None:
"""Show search input."""
search_input = self.query_one("#search-input", Input)
search_input.display = True
self._search_visible = True
search_input.focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle search submission."""
if event.input.id == "search-input":
pattern = event.value
viewer = self.query_one("#file-viewer", FileContentViewer)
viewer.search(pattern)
# Hide search input and refocus viewer
search_input = self.query_one("#search-input", Input)
search_input.display = False
self._search_visible = False
viewer.focus()
def on_key(self, event) -> None:
"""Handle escape key to close search input."""
if event.key == "escape" and self._search_visible:
search_input = self.query_one("#search-input", Input)
search_input.display = False
self._search_visible = False
viewer = self.query_one("#file-viewer", FileContentViewer)
viewer.focus()
event.stop()
event.prevent_default()
class SupportAnalyzerApp(App):
"""Main TUI application for analyzing support data."""
CSS = """
Screen {
background: $surface;
}
#file-tree {
width: 40%;
border: solid $primary;
padding: 1;
}
#file-viewer {
width: 60%;
border: solid $primary;
padding: 1;
}
SummaryView {
width: 100%;
height: 100%;
border: solid $primary;
padding: 1;
}
.summary-instructions {
width: 100%;
height: auto;
padding: 0 1 1 1;
color: $text-muted;
}
#archive-table {
width: 100%;
height: 100%;
}
.summary-content {
width: 100%;
height: auto;
}
.file-content {
width: 100%;
height: auto;
}
.error {
color: $error;
}
Tree {
background: $panel;
}
/* Comparison view styles */
.comparison-pane {
width: 50%;
border: solid $primary;
padding: 1;
}
.file-selector-label {
height: 1;
padding: 0 1;
}
.file-selector {
margin: 0 0 1 0;
}
"""
BINDINGS = [
Binding("q", "quit", "Quit", show=True, priority=True),
Binding("s", "show_summary", "Summary", show=True),
Binding("a", "show_analyze", "Analyze", show=True),
Binding("c", "show_compare", "Compare", show=True),
Binding("l", "toggle_lock", "Lock Scroll", show=False), # Only show in compare mode
Binding("?", "help", "Help", show=True),
]
current_mode = reactive("summary") # Reactive property for mode switching
def __init__(self, archives: list, start_mode: str = "summary"):
super().__init__()
self.archives = archives # All available archives
self.selected_archives = archives # Currently selected archives for analyze/compare
self.title = "Infix Support Analyzer"
# Set initial mode without triggering watch (set directly on the descriptor)
self._initial_mode = start_mode
def compose(self) -> ComposeResult:
"""Create the app layout."""
yield Header()
# Use initial mode for compose, then set reactive property after
mode = getattr(self, '_initial_mode', 'summary')
yield from self._get_view_for_mode(mode)
yield Footer()
def on_mount(self) -> None:
"""Called when app is mounted - safe to set reactive properties now."""
# Now set the reactive property which will trigger watch if changed
initial_mode = getattr(self, '_initial_mode', 'summary')
self.current_mode = initial_mode
def _get_view_for_mode(self, mode: str):
"""Get the appropriate view widget(s) for a mode."""
if mode == "summary":
yield SummaryView(self.archives, id="main-view")
elif mode == "analyze":
if len(self.selected_archives) > 0:
yield SingleArchiveView(self.selected_archives[0], id="main-view")
else:
yield Label("No archives to analyze", id="main-view")
elif mode == "compare":
if len(self.selected_archives) >= 2:
yield ComparisonView(self.selected_archives[0], self.selected_archives[1], id="main-view")
else:
yield Label(f"Comparison requires 2 archives. Found: {len(self.selected_archives)}\n"
"Press 's' for summary", id="main-view")
else:
yield Label(f"Unknown mode: {mode}", id="main-view")
def action_show_summary(self) -> None:
"""Switch to summary view."""
self.current_mode = "summary"
def action_show_analyze(self) -> None:
"""Switch to analyze view."""
if len(self.selected_archives) == 0:
self.notify("No archives selected. Press 's' for summary to select.", severity="warning", timeout=2)
else:
self.current_mode = "analyze"
def action_show_compare(self) -> None:
"""Switch to compare view."""
if len(self.selected_archives) < 2:
self.notify(f"Compare requires 2 archives. Selected: {len(self.selected_archives)}. Press 's' for summary.",
severity="warning", timeout=3)
else:
self.current_mode = "compare"
def watch_current_mode(self, old_mode: str, new_mode: str) -> None:
"""React to mode changes (Textual reactive watch method)."""
if old_mode == new_mode:
return
# Remove old view
try:
old_view = self.query_one("#main-view")
old_view.remove()
# Wait for removal to complete before mounting new widget
self.call_after_refresh(self._mount_new_view, new_mode)
except:
# No old view, just mount directly
self._mount_new_view(new_mode)
def _mount_new_view(self, mode: str) -> None:
"""Mount the view for the given mode."""
for widget in self._get_view_for_mode(mode):
footer = self.query_one(Footer)
self.mount(widget, before=footer)
def action_toggle_lock(self) -> None:
"""Toggle scroll lock in comparison view."""
try:
comparison_view = self.query_one("#main-view", ComparisonView)
comparison_view.action_toggle_lock()
except:
self.notify("Lock scroll only available in compare mode", timeout=2)
def action_help(self) -> None:
"""Show help dialog."""
help_text = (
"Keybindings:\n"
" q = Quit\n"
" s = Summary view\n"
" a = Analyze view (single archive)\n"
" c = Compare view (dual pane)\n"
" l = Lock/unlock scroll (compare mode)\n"
" Arrow keys = Navigate\n"
"\n"
"Compare mode:\n"
" Use dropdown menus to select different files in each pane\n"
" Files can be different between left and right\n"
"\n"
"Text selection: Click and drag, Ctrl+C to copy"
)
self.notify(help_text, title="Help", timeout=8)
def launch_tui(archives: list, mode: str = "summary"):
"""Launch the TUI with given archives and mode."""
app = SupportAnalyzerApp(archives, mode)
app.run()