feat: deep link diagnostics via Pulse SSH

Adds comprehensive per-port link troubleshooting triggered from the
Inspector panel when a port has an LLDP-identified server counterpart.

- diagnose.py: DiagnosticsRunner with 15-section SSH command (carrier,
  operstate, sysfs counters, ethtool, ethtool -i/-a/-g/-S/-m, ip link,
  ip addr, ip route, dmesg, lldpctl); parsers for all sections; health
  analyzer with 14 check codes (NO_CARRIER, HALF_DUPLEX, SPEED_MISMATCH,
  SFP_RX_CRITICAL, CARRIER_FLAPPING, CRC_ERRORS_HIGH, LLDP_MISMATCH, etc.)
- monitor.py: PulseClient now tracks last_execution_id so callers can
  link back to the raw Pulse execution URL
- app.py: POST /api/diagnose + GET /api/diagnose/<job_id> with daemon
  thread background execution and 10-minute in-memory job store
- inspector.html: "Run Link Diagnostics" button (shown only when LLDP
  host is resolvable); full results panel: health banner, physical layer,
  SFP/DOM with power bars, NIC error counters, collapsible ethtool -S,
  flow control/ring buffers, driver info, LLDP 2-col validation,
  collapsible dmesg, switch port summary, "View in Pulse" link
- style.css: all .diag-* CSS classes with terminal aesthetic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 16:03:54 -05:00
parent 0278dad502
commit b1dd5f9cad
5 changed files with 1272 additions and 0 deletions

View File

@@ -239,6 +239,7 @@ class PulseClient:
self.api_key = p.get('api_key', '')
self.worker_id = p.get('worker_id', '')
self.timeout = p.get('timeout', 45)
self.last_execution_id: Optional[str] = None
self.session = requests.Session()
self.session.headers.update({
'X-Gandalf-API-Key': self.api_key,
@@ -247,6 +248,7 @@ class PulseClient:
def run_command(self, command: str) -> Optional[str]:
"""Submit *command* to Pulse, poll until done, return stdout or None."""
self.last_execution_id = None
if not self.url or not self.api_key or not self.worker_id:
return None
try:
@@ -257,6 +259,7 @@ class PulseClient:
)
resp.raise_for_status()
execution_id = resp.json()['execution_id']
self.last_execution_id = execution_id
except Exception as e:
logger.debug(f'Pulse command submit failed: {e}')
return None