Add live SNMP walk server, UI controls, and viewer enhancements

- Add nid-server.py: Python web server (stdlib) with live walk API,
  SSE progress streaming, and clear/archive endpoints
- Add snmp-walk.sh: CLI wrapper for walk pipeline with .env config
- Add walk control card to viewer UI with IP input, mode selector,
  walk/clear buttons, and real-time progress bar
- Make cards collapsible, add management IP to header
- Add dynamic port type rendering (SFP vs RJ45 from connector table)
- Add SFF-8024 connector type labels for SFP detail cards
- Fix ifOperStatus numeric vs text comparisons for live walk data
- Add alarm config-only fallback when device lacks status table
- Use snmpbulkwalk for faster walks with parallel subtree execution
- Add .env/.env.example for secrets and config, gitignore walks/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sam 2026-03-02 15:20:27 -07:00
parent dfdbd85bf7
commit 2b10edbb7b
16 changed files with 1328 additions and 162474 deletions

26
.env.example Normal file
View File

@ -0,0 +1,26 @@
# ── SNMP NID Viewer Environment Configuration ──
# Copy to .env and edit as needed. This file is gitignored.
# Target device IP (override via CLI: ./snmp-walk.sh 10.0.0.1)
SNMP_TARGET=10.13.60.102
# ── SNMPv2c ──
SNMP_VERSION=2c
SNMP_COMMUNITY=public
# ── SNMPv3 (future uncomment and set when needed) ──
# SNMP_VERSION=3
# SNMP_V3_USER=
# SNMP_V3_AUTH_PROTO=SHA # MD5 | SHA | SHA-256 | SHA-512
# SNMP_V3_AUTH_PASS=
# SNMP_V3_PRIV_PROTO=AES # DES | AES | AES-256
# SNMP_V3_PRIV_PASS=
# SNMP_V3_SEC_LEVEL=authPriv # noAuthNoPriv | authNoPriv | authPriv
# ── Walk mode ──
# "full" = walk entire .1 tree (captures everything, ~27% larger)
# "targeted" = walk only subtrees used by the viewer (faster)
SNMP_WALK_MODE=targeted
# ── Server ──
SERVER_PORT=5525

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Environment secrets
.env
# Python
__pycache__/
# Walk data (device-specific, generated)
walks/

File diff suppressed because it is too large Load Diff

374
nid-server.py Normal file
View File

@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""
SNMP NID Viewer Web Server
Serves the NID viewer HTML and provides API endpoints to trigger
live SNMP walks from the browser UI.
Routes:
GET / Viewer page (built from latest monitoring JSON)
POST /api/walk Trigger a walk { "target": "x.x.x.x", "mode": "targeted"|"full" }
GET /api/status SSE stream of walk progress
Usage:
python3 nid-server.py
"""
import json
import os
import re
import shutil
import subprocess
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
WALKS_DIR = SCRIPT_DIR / "walks"
# ── OID subtrees for targeted walk (mirrors snmp-walk.sh) ────────────
TARGETED_OIDS = [
(".1.3.6.1.2.1.1", "System"),
(".1.3.6.1.2.1.2", "IF-MIB"),
(".1.3.6.1.2.1.4", "IP-MIB"),
(".1.3.6.1.2.1.31", "IF-MIB-X"),
(".1.3.6.1.2.1.55", "IPv6-MIB"),
(".1.3.111.2.802.1.1.13", "LLDP-MIB"),
(".1.3.6.1.4.1.22420.1.1", "ACD-DESC-MIB"),
(".1.3.6.1.4.1.22420.2.1", "ACD-ALARM-MIB"),
(".1.3.6.1.4.1.22420.2.2", "ACD-FILTER-MIB"),
(".1.3.6.1.4.1.22420.2.3", "ACD-POLICY-MIB"),
(".1.3.6.1.4.1.22420.2.4", "ACD-SFP-MIB"),
(".1.3.6.1.4.1.22420.2.6", "ACD-REGULATOR-MIB"),
(".1.3.6.1.4.1.22420.2.8", "ACD-SMAP-MIB"),
(".1.3.6.1.4.1.22420.2.9", "ACD-PORT-MIB"),
]
# ── Environment / config ─────────────────────────────────────────────
def load_env(path: Path) -> dict:
"""Parse a .env file into a dict (simple key=value, skip comments)."""
env = {}
if not path.is_file():
return env
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, val = line.partition("=")
env[key.strip()] = val.strip().strip('"').strip("'")
return env
ENV = load_env(SCRIPT_DIR / ".env")
SERVER_PORT = int(ENV.get("SERVER_PORT", "5525"))
SNMP_VERSION = ENV.get("SNMP_VERSION", "2c")
SNMP_COMMUNITY = ENV.get("SNMP_COMMUNITY", "public")
# SNMPv3 fields (future)
SNMP_V3_USER = ENV.get("SNMP_V3_USER", "")
SNMP_V3_AUTH_PROTO = ENV.get("SNMP_V3_AUTH_PROTO", "SHA")
SNMP_V3_AUTH_PASS = ENV.get("SNMP_V3_AUTH_PASS", "")
SNMP_V3_PRIV_PROTO = ENV.get("SNMP_V3_PRIV_PROTO", "AES")
SNMP_V3_PRIV_PASS = ENV.get("SNMP_V3_PRIV_PASS", "")
SNMP_V3_SEC_LEVEL = ENV.get("SNMP_V3_SEC_LEVEL", "authPriv")
# ── Walk state (shared across threads) ───────────────────────────────
walk_lock = threading.Lock()
walk_status = {
"state": "idle", # idle | walking | parsing | building | complete | error
"message": "",
"progress": 0, # 0-100
"timestamp": None,
"lines": 0,
"elapsed": 0,
}
# Monotonically increasing version so SSE clients know when state changed
walk_version = 0
# Path to latest monitoring JSON (set after successful walk)
latest_json = None
def set_status(state, message="", progress=0, **extra):
global walk_version
with walk_lock:
walk_status["state"] = state
walk_status["message"] = message
walk_status["progress"] = progress
walk_status.update(extra)
walk_version += 1
def get_status():
with walk_lock:
return dict(walk_status), walk_version
# ── SNMP walk execution ──────────────────────────────────────────────
def build_snmp_auth() -> list:
"""Build snmpwalk authentication flags from env config."""
if SNMP_VERSION == "3":
args = ["-v3", "-u", SNMP_V3_USER, "-l", SNMP_V3_SEC_LEVEL]
if SNMP_V3_SEC_LEVEL != "noAuthNoPriv":
args += ["-a", SNMP_V3_AUTH_PROTO, "-A", SNMP_V3_AUTH_PASS]
if SNMP_V3_SEC_LEVEL == "authPriv":
args += ["-x", SNMP_V3_PRIV_PROTO, "-X", SNMP_V3_PRIV_PASS]
return args
return ["-v", SNMP_VERSION, "-c", SNMP_COMMUNITY]
def run_walk(target: str, mode: str):
"""Execute the full walk pipeline in a background thread."""
global latest_json
ip_re = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
if not ip_re.match(target):
set_status("error", message=f"Invalid IP address: {target}")
return
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
safe_ip = target.replace(".", "-")
walk_file = WALKS_DIR / f"{safe_ip}_{timestamp}_walk.txt"
WALKS_DIR.mkdir(parents=True, exist_ok=True)
auth = build_snmp_auth()
t_start = time.time()
try:
# ── Step 1: snmpwalk ──────────────────────────────────────
# Use snmpbulkwalk (GETBULK PDUs) when available — much faster
walk_cmd = "snmpbulkwalk" if shutil.which("snmpbulkwalk") else "snmpwalk"
bulk_args = [] # use snmpbulkwalk default (-Cr10); higher values truncate on some devices
if mode == "full":
set_status("walking", message="Walking full OID tree (.1)", progress=5)
result = subprocess.run(
[walk_cmd, "-On", "-OQ"] + bulk_args + auth + [target, ".1"],
capture_output=True, text=True, timeout=300,
)
walk_file.write_text(result.stdout)
else:
# Walk subtrees in parallel for speed
total = len(TARGETED_OIDS)
completed = [0] # mutable counter for progress
results_map = {}
def walk_subtree(idx, oid, label):
res = subprocess.run(
[walk_cmd, "-On", "-OQ"] + bulk_args + auth + [target, oid],
capture_output=True, text=True, timeout=120,
)
completed[0] += 1
pct = int((completed[0] / total) * 70)
set_status("walking", message=f"Walking subtrees ({completed[0]}/{total})", progress=pct)
return idx, res.stdout
set_status("walking", message=f"Walking {total} subtrees in parallel", progress=5)
with ThreadPoolExecutor(max_workers=4) as pool:
futures = [
pool.submit(walk_subtree, i, oid, label)
for i, (oid, label) in enumerate(TARGETED_OIDS)
]
for fut in as_completed(futures):
idx, output = fut.result()
if output.strip():
results_map[idx] = output
# Reassemble in OID order
output_lines = [results_map[i] for i in sorted(results_map)]
walk_file.write_text("\n".join(output_lines))
line_count = sum(1 for _ in walk_file.open())
elapsed = round(time.time() - t_start, 1)
set_status("walking", message=f"Walk complete: {line_count:,} lines in {elapsed}s",
progress=72, lines=line_count)
if line_count == 0:
set_status("error", message=f"Walk returned no data — check reachability and credentials",
elapsed=elapsed)
return
# ── Step 2: snmp-parse.py ─────────────────────────────────
set_status("parsing", message="Parsing SNMP data", progress=78)
parse_result = subprocess.run(
[sys.executable, str(SCRIPT_DIR / "snmp-parse.py"), str(walk_file)],
capture_output=True, text=True, timeout=120,
)
if parse_result.returncode != 0:
set_status("error", message=f"Parse failed: {parse_result.stderr[:200]}")
return
monitoring_json = walk_file.with_name(walk_file.stem + "_monitoring.json")
if not monitoring_json.is_file():
set_status("error", message="Parser did not produce monitoring JSON")
return
latest_json = monitoring_json
elapsed = round(time.time() - t_start, 1)
set_status("complete",
message=f"Done — {line_count:,} lines in {elapsed}s",
progress=100, lines=line_count, elapsed=elapsed,
timestamp=datetime.now().isoformat())
except subprocess.TimeoutExpired:
set_status("error", message="Walk timed out — device may be unreachable")
except Exception as e:
set_status("error", message=str(e)[:300])
# ── Find latest monitoring JSON ──────────────────────────────────────
def find_latest_json() -> Path | None:
"""Return the most recent *_monitoring.json in walks/."""
if latest_json and latest_json.is_file():
return latest_json
candidates = sorted(WALKS_DIR.glob("*_monitoring.json"), key=lambda p: p.stat().st_mtime)
return candidates[-1] if candidates else None
# ── HTTP handler ─────────────────────────────────────────────────────
class NIDHandler(BaseHTTPRequestHandler):
"""Serve viewer and handle walk API."""
def log_message(self, format, *args):
# Cleaner logging
print(f"[{self.log_date_time_string()}] {format % args}")
def _send(self, code, content_type, body):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _send_json(self, code, obj):
self._send(code, "application/json", json.dumps(obj).encode())
# ── GET ────────────────────────────────────────────────────────
def do_GET(self):
if self.path == "/":
self._serve_viewer()
elif self.path == "/api/status":
self._serve_sse()
else:
self._send(404, "text/plain", b"Not found")
def _serve_viewer(self):
"""Build and serve the viewer HTML from latest data."""
# Import build_html lazily to avoid circular issues at module level
sys.path.insert(0, str(SCRIPT_DIR))
from build_nid_viewer import build_html
json_path = find_latest_json()
if json_path:
with json_path.open() as f:
data = json.load(f)
else:
data = {}
html = build_html(data)
self._send(200, "text/html; charset=utf-8", html.encode())
def _serve_sse(self):
"""Stream walk status as Server-Sent Events."""
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "keep-alive")
self.end_headers()
last_version = -1
try:
while True:
status, version = get_status()
if version != last_version:
msg = f"data: {json.dumps(status)}\n\n"
self.wfile.write(msg.encode())
self.wfile.flush()
last_version = version
# Close SSE after terminal states so client triggers reload
if status["state"] in ("complete", "error"):
break
time.sleep(0.3)
except (BrokenPipeError, ConnectionResetError):
pass
# ── POST ───────────────────────────────────────────────────────
def do_POST(self):
if self.path == "/api/walk":
self._handle_walk()
elif self.path == "/api/clear":
self._handle_clear()
else:
self._send(404, "text/plain", b"Not found")
def _handle_walk(self):
"""Start a walk in a background thread."""
current, _ = get_status()
if current["state"] in ("walking", "parsing"):
self._send_json(409, {"error": "Walk already in progress"})
return
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length else {}
target = body.get("target", "").strip()
mode = body.get("mode", "targeted").strip()
if not target:
self._send_json(400, {"error": "target IP required"})
return
if mode not in ("targeted", "full"):
self._send_json(400, {"error": "mode must be 'targeted' or 'full'"})
return
set_status("walking", message="Starting walk...", progress=1)
thread = threading.Thread(target=run_walk, args=(target, mode), daemon=True)
thread.start()
self._send_json(200, {"status": "started", "target": target, "mode": mode})
def _handle_clear(self):
"""Move all walk data to walks/archive/ and reset state."""
global latest_json
archive = WALKS_DIR / "archive"
archive.mkdir(parents=True, exist_ok=True)
moved = 0
for f in WALKS_DIR.iterdir():
if f.is_file():
f.rename(archive / f.name)
moved += 1
latest_json = None
set_status("idle")
self._send_json(200, {"status": "cleared", "files_archived": moved})
# ── Main ─────────────────────────────────────────────────────────────
def main():
server = ThreadingHTTPServer(("0.0.0.0", SERVER_PORT), NIDHandler)
print(f"╔═══════════════════════════════════════════════════╗")
print(f"║ SNMP NID Viewer — Web Server ║")
print(f"║ http://localhost:{SERVER_PORT:<5}")
print(f"╚═══════════════════════════════════════════════════╝")
print(f" SNMPv{SNMP_VERSION} | Press Ctrl+C to stop")
print()
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down.")
server.shutdown()
if __name__ == "__main__":
main()

View File

@ -995,6 +995,18 @@ def build_monitoring_output(walk_data: dict, resolver: OIDResolver) -> dict:
if ip_addresses:
output["ip_addresses"] = ip_addresses
# Back-fill interfaces for any ifIndex referenced by IP addresses
# but missing from the IF-MIB walk (common on Accedian virtual/internal interfaces).
# Mark them as synthetic so the viewer can distinguish them from real interfaces.
for ip_entry in ip_addresses.values():
if_idx = ip_entry.get("ifIndex", "")
if if_idx and if_idx not in interfaces:
interfaces[if_idx] = OrderedDict([
("ifDescr", f"Virtual ({if_idx})"),
("ifName", f"Virtual ({if_idx})"),
("synthetic", "1"),
])
# ── 23. LLDP Neighbors (structured) ──
LLDP_PREFIX = ".1.3.111.2.802.1.1.13"
LLDP_REM_TABLE = LLDP_PREFIX + ".1.4.1.1." # lldpRemTable columns

175
snmp-walk.sh Executable file
View File

@ -0,0 +1,175 @@
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────────────
# snmp-walk.sh — Live SNMP walk → parse → HTML viewer pipeline
#
# Usage:
# ./snmp-walk.sh # uses SNMP_TARGET from .env
# ./snmp-walk.sh 10.0.0.1 # override target IP
# ./snmp-walk.sh 10.0.0.1 full # override target + walk mode
#
# Reads configuration from .env (community, version, walk mode).
# Produces: walks/{IP}_{TIMESTAMP}_walk.txt
# walks/{IP}_{TIMESTAMP}_walk_monitoring.json
# walks/nid-viewer.html
# ──────────────────────────────────────────────────────────────────────
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${SCRIPT_DIR}/.env"
# ── Load .env ────────────────────────────────────────────────────────
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck source=/dev/null
source "$ENV_FILE"
set +a
else
echo "Warning: ${ENV_FILE} not found — using defaults" >&2
fi
# ── Resolve parameters (CLI overrides .env) ──────────────────────────
TARGET="${1:-${SNMP_TARGET:-}}"
WALK_MODE="${2:-${SNMP_WALK_MODE:-targeted}}"
VERSION="${SNMP_VERSION:-2c}"
if [[ -z "$TARGET" ]]; then
echo "Error: No target IP specified."
echo " Set SNMP_TARGET in .env or pass as argument: $0 <ip>"
exit 1
fi
# ── Validate prerequisites ───────────────────────────────────────────
for cmd in snmpwalk python3; do
if ! command -v "$cmd" &>/dev/null; then
echo "Error: '$cmd' not found. Please install it." >&2
exit 1
fi
done
# Prefer snmpbulkwalk (GETBULK PDUs — much faster) over snmpwalk (GETNEXT)
if command -v snmpbulkwalk &>/dev/null; then
WALK_CMD=snmpbulkwalk
BULK_ARGS=() # use default -Cr10; higher values truncate on some devices
else
WALK_CMD=snmpwalk
BULK_ARGS=()
fi
# ── Build snmpwalk auth flags ────────────────────────────────────────
SNMP_AUTH=()
if [[ "$VERSION" == "3" ]]; then
SNMP_AUTH+=(-v3)
SNMP_AUTH+=(-u "${SNMP_V3_USER:?SNMP_V3_USER required for v3}")
SNMP_AUTH+=(-l "${SNMP_V3_SEC_LEVEL:-authPriv}")
if [[ "${SNMP_V3_SEC_LEVEL:-authPriv}" != "noAuthNoPriv" ]]; then
SNMP_AUTH+=(-a "${SNMP_V3_AUTH_PROTO:-SHA}")
SNMP_AUTH+=(-A "${SNMP_V3_AUTH_PASS:?SNMP_V3_AUTH_PASS required}")
fi
if [[ "${SNMP_V3_SEC_LEVEL:-authPriv}" == "authPriv" ]]; then
SNMP_AUTH+=(-x "${SNMP_V3_PRIV_PROTO:-AES}")
SNMP_AUTH+=(-X "${SNMP_V3_PRIV_PASS:?SNMP_V3_PRIV_PASS required}")
fi
else
SNMP_AUTH+=(-v "${VERSION}" -c "${SNMP_COMMUNITY:-public}")
fi
# ── Define OID subtrees ──────────────────────────────────────────────
# Targeted: only what the viewer/parser needs
TARGETED_OIDS=(
.1.3.6.1.2.1.1 # System (sysDescr, sysName, sysUpTime, …)
.1.3.6.1.2.1.2 # IF-MIB (interface table)
.1.3.6.1.2.1.4 # IP-MIB (addresses, routes)
.1.3.6.1.2.1.31 # IF-MIB extensions (ifName, ifAlias, 64-bit counters)
.1.3.6.1.2.1.55 # IPv6-MIB
.1.3.111.2.802.1.1.13 # LLDP-MIB (neighbors, stats)
.1.3.6.1.4.1.22420.1.1 # ACD-DESC-MIB (device identity, connectors, sensors)
.1.3.6.1.4.1.22420.2.1 # ACD-ALARM-MIB
.1.3.6.1.4.1.22420.2.2 # ACD-FILTER-MIB
.1.3.6.1.4.1.22420.2.3 # ACD-POLICY-MIB (L2 policies, stats)
.1.3.6.1.4.1.22420.2.4 # ACD-SFP-MIB (transceiver info/diag)
.1.3.6.1.4.1.22420.2.6 # ACD-REGULATOR-MIB
.1.3.6.1.4.1.22420.2.8 # ACD-SMAP-MIB (CoS profiles)
.1.3.6.1.4.1.22420.2.9 # ACD-PORT-MIB (port config/status)
)
# ── Prepare output paths ─────────────────────────────────────────────
TIMESTAMP="$(date +%Y-%m-%d_%H-%M-%S)"
SAFE_IP="${TARGET//./-}"
WALKS_DIR="${SCRIPT_DIR}/walks"
WALK_FILE="${WALKS_DIR}/${SAFE_IP}_${TIMESTAMP}_walk.txt"
ERROR_FILE="${WALKS_DIR}/${SAFE_IP}_${TIMESTAMP}_errors.txt"
mkdir -p "$WALKS_DIR"
# ── Execute SNMP walk ─────────────────────────────────────────────────
echo "═══════════════════════════════════════════════════════"
echo " SNMP NID Viewer — Live Walk"
echo "═══════════════════════════════════════════════════════"
echo " Target: ${TARGET}"
echo " Version: SNMPv${VERSION}"
echo " Mode: ${WALK_MODE}"
echo " Walker: ${WALK_CMD}${BULK_ARGS:+ (max-rep=${BULK_ARGS[1]#-Cr})}"
echo " Output: ${WALK_FILE}"
echo "═══════════════════════════════════════════════════════"
echo ""
WALK_START="$(date +%s)"
if [[ "$WALK_MODE" == "full" ]]; then
echo "[1/3] Walking full OID tree (.1) ..."
"$WALK_CMD" -On -OQ "${BULK_ARGS[@]}" "${SNMP_AUTH[@]}" "$TARGET" .1 \
> "$WALK_FILE" 2> "$ERROR_FILE" || true
else
echo "[1/3] Walking ${#TARGETED_OIDS[@]} targeted subtrees ..."
: > "$WALK_FILE"
: > "$ERROR_FILE"
for oid in "${TARGETED_OIDS[@]}"; do
echo "${oid}"
"$WALK_CMD" -On -OQ "${BULK_ARGS[@]}" "${SNMP_AUTH[@]}" "$TARGET" "$oid" \
>> "$WALK_FILE" 2>> "$ERROR_FILE" || true
done
fi
WALK_END="$(date +%s)"
WALK_LINES="$(wc -l < "$WALK_FILE")"
WALK_SECS=$(( WALK_END - WALK_START ))
echo " ✓ Walk complete: ${WALK_LINES} lines in ${WALK_SECS}s"
# Remove empty error file
if [[ ! -s "$ERROR_FILE" ]]; then
rm -f "$ERROR_FILE"
fi
if [[ "$WALK_LINES" -eq 0 ]]; then
echo ""
echo "Error: Walk returned no data. Check:"
echo " - Device reachability (ping ${TARGET})"
echo " - SNMP community/credentials in .env"
echo " - Firewall rules (UDP 161)"
[[ -s "$ERROR_FILE" ]] && echo "" && cat "$ERROR_FILE"
exit 1
fi
# ── Parse walk → monitoring JSON ──────────────────────────────────────
echo ""
echo "[2/3] Parsing walk data ..."
python3 "${SCRIPT_DIR}/snmp-parse.py" "$WALK_FILE"
MONITORING_JSON="${WALK_FILE%.txt}_monitoring.json"
if [[ ! -f "$MONITORING_JSON" ]]; then
echo "Error: snmp-parse.py did not produce ${MONITORING_JSON}" >&2
exit 1
fi
echo " ✓ Monitoring JSON: ${MONITORING_JSON}"
# ── Build HTML viewer ─────────────────────────────────────────────────
echo ""
echo "[3/3] Building HTML viewer ..."
python3 "${SCRIPT_DIR}/build_nid_viewer.py" "$MONITORING_JSON"
echo " ✓ Viewer: ${WALKS_DIR}/nid-viewer.html"
# ── Summary ───────────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════════════════"
echo " Done! Open walks/nid-viewer.html in a browser."
echo "═══════════════════════════════════════════════════════"

View File

@ -1 +0,0 @@
Unknown flag passed to -C: r

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long