From f44d70d097b060735784f7c33400b768ee7c0391 Mon Sep 17 00:00:00 2001 From: sunnyhe2 Date: Mon, 6 Jul 2026 15:38:44 -0700 Subject: [PATCH] switch error field add --- nodescraper/command_artifact_html.py | 59 ++++++++++++ .../scale_out_arista/scaleoutaristadata.py | 80 ++++++++-------- .../switch/scale_out_dell/scaleoutdelldata.py | 94 +++++++++---------- .../inband/switch/switch_analyzer_base.py | 21 ++++- test/unit/plugin/test_switch_analyzer_base.py | 81 ++++++++++++++++ 5 files changed, 246 insertions(+), 89 deletions(-) diff --git a/nodescraper/command_artifact_html.py b/nodescraper/command_artifact_html.py index 5bf3c5be..18e93606 100644 --- a/nodescraper/command_artifact_html.py +++ b/nodescraper/command_artifact_html.py @@ -121,6 +121,17 @@ } pre.stderr { color: var(--fail-fg); } .no-results { color: var(--muted); padding: 24px; text-align: center; display: none; } + .copy-btn { + flex: 0 0 auto; + display: inline-flex; align-items: center; justify-content: center; + background: var(--bg); color: var(--muted); + border: 1px solid var(--border); border-radius: 6px; + padding: 5px 7px; cursor: pointer; line-height: 0; + transition: color .15s ease, border-color .15s ease, background .15s ease; + } + .copy-btn:hover { background: var(--panel-hover); border-color: var(--accent); color: var(--text); } + .copy-btn.copied { color: var(--ok-fg); border-color: var(--ok-fg); } + .copy-btn svg { display: block; } @@ -143,6 +154,9 @@ {command} exit {exit_code} +
{stdout_block} @@ -176,6 +190,51 @@ document.getElementById('collapseAll').addEventListener('click', () => { items.forEach(d => d.open = false); }); + + const copyButtons = Array.from(document.querySelectorAll('.copy-btn')); + const COPY_SVG = copyButtons.length ? copyButtons[0].innerHTML : ''; + const CHECK_SVG = ''; + + function showCopied(btn) { + btn.classList.add('copied'); + btn.innerHTML = CHECK_SVG; + if (btn._resetTimer) clearTimeout(btn._resetTimer); + btn._resetTimer = setTimeout(() => { + btn.classList.remove('copied'); + btn.innerHTML = COPY_SVG; + }, 1200); + } + + function fallbackCopy(text, btn) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.top = '-1000px'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { document.execCommand('copy'); showCopied(btn); } catch (e) {} + document.body.removeChild(ta); + } + + copyButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const card = btn.closest('details.cmd'); + const parts = []; + card.querySelectorAll('pre').forEach(pre => { + if (!pre.classList.contains('empty')) parts.push(pre.textContent); + }); + const text = parts.join('\\n'); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(() => showCopied(btn)).catch(() => fallbackCopy(text, btn)); + } else { + fallbackCopy(text, btn); + } + }); + }); diff --git a/nodescraper/plugins/inband/switch/scale_out_arista/scaleoutaristadata.py b/nodescraper/plugins/inband/switch/scale_out_arista/scaleoutaristadata.py index 1c48a8da..041a8c0c 100644 --- a/nodescraper/plugins/inband/switch/scale_out_arista/scaleoutaristadata.py +++ b/nodescraper/plugins/inband/switch/scale_out_arista/scaleoutaristadata.py @@ -24,7 +24,7 @@ # ############################################################################### -from typing import ClassVar, Dict, List, Optional, Union +from typing import ClassVar, Dict, List, Optional from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel @@ -98,8 +98,8 @@ class FanConfiguration(BaseModel): speed_hw_override: Optional[bool] = None speed_stable: Optional[bool] = None - error_fields: ClassVar[dict[str, str]] = { - "status": "ok", + error_fields: ClassVar[dict[str, List[str]]] = { + "status": ["ok"], } @@ -124,9 +124,9 @@ class AristaSystemEnv(BaseModel): power_supply_slots: Optional[List[FanConfiguration]] = None fan_tray_slots: Optional[List[FanConfiguration]] = None - error_fields: ClassVar[dict[str, Union[str, bool]]] = { - "system_status": "coolingOk", - "fans_status": "fanAlarmOk", + error_fields: ClassVar[dict[str, List[str]]] = { + "system_status": ["coolingOk"], + "fans_status": ["fanAlarmOk"], } @@ -139,9 +139,9 @@ class VlanInformation(BaseModel): interface_mode: Optional[str] = None interface_forwarding_model: Optional[str] = None - error_fields: ClassVar[dict[str, str]] = { - "interface_mode": "routed", - "interface_forwarding_model": "routed", + error_fields: ClassVar[dict[str, List[str]]] = { + "interface_mode": ["routed"], + "interface_forwarding_model": ["routed"], } @@ -160,10 +160,10 @@ class AristaPortStatus(BaseModel): line_protocol_status: Optional[str] = None interface_damped: Optional[bool] = None - error_fields: ClassVar[dict[str, str]] = { - "link_status": "connected", - "duplex": "duplexFull", - "line_protocol_status": "up", + error_fields: ClassVar[dict[str, List[str]]] = { + "link_status": ["connected"], + "duplex": ["duplexFull"], + "line_protocol_status": ["up"], } @@ -180,14 +180,14 @@ class AristaCountersErrors(BaseModel): alignment_errors: Optional[int] = None symbol_errors: Optional[int] = None - error_fields: ClassVar[dict[str, str]] = { - "in_errors": "0", - "frame_too_longs": "0", - "out_errors": "0", - "frame_too_shorts": "0", - "fcs_errors": "0", - "alignment_errors": "0", - "symbol_errors": "0", + error_fields: ClassVar[dict[str, List[str]]] = { + "in_errors": ["0"], + "frame_too_longs": ["0"], + "out_errors": ["0"], + "frame_too_shorts": ["0"], + "fcs_errors": ["0"], + "alignment_errors": ["0"], + "symbol_errors": ["0"], } @@ -259,10 +259,10 @@ class AristaDroppedPacketCounters(BaseModel): out_uc_dropped_pkts: Optional[int] = None out_mc_dropped_pkts: Optional[int] = None - warning_fields: ClassVar[dict[str, str]] = { - "in_dropped_pkts": "0", - "out_uc_dropped_pkts": "0", - "out_mc_dropped_pkts": "0", + warning_fields: ClassVar[dict[str, List[str]]] = { + "in_dropped_pkts": ["0"], + "out_uc_dropped_pkts": ["0"], + "out_mc_dropped_pkts": ["0"], } @@ -275,10 +275,10 @@ class AristaDropPrecedenceCounters(BaseModel): dp1_dropped_pkts: Optional[int] = None dp2_dropped_pkts: Optional[int] = None - error_fields: ClassVar[dict[str, str]] = { - "dp0_dropped_pkts": "0", - "dp1_dropped_pkts": "0", - "dp2_dropped_pkts": "0", + error_fields: ClassVar[dict[str, List[str]]] = { + "dp0_dropped_pkts": ["0"], + "dp1_dropped_pkts": ["0"], + "dp2_dropped_pkts": ["0"], } @@ -293,9 +293,9 @@ class AristaPerQueueCounters(BaseModel): pkts_drop: Optional[int] = None bytes_drop: Optional[int] = None - warning_fields: ClassVar[dict[str, str]] = { - "pkts_drop": "0", - "bytes_drop": "0", + warning_fields: ClassVar[dict[str, List[str]]] = { + "pkts_drop": ["0"], + "bytes_drop": ["0"], } @@ -311,9 +311,9 @@ class AristaPauseFrameCounters(BaseModel): tx_pause: Optional[int] = None rx_pause: Optional[int] = None - error_fields: ClassVar[dict[str, str]] = { - "tx_pause": "0", - "rx_pause": "0", + error_fields: ClassVar[dict[str, List[str]]] = { + "tx_pause": ["0"], + "rx_pause": ["0"], } @@ -325,8 +325,8 @@ class AristaEcnCounters(BaseModel): txq: Optional[str] = None marked_packets: Optional[str] = None - warning_fields: ClassVar[dict[str, str]] = { - "marked_packets": "0", + warning_fields: ClassVar[dict[str, List[str]]] = { + "marked_packets": ["0", "-"], } @@ -338,9 +338,9 @@ class AristaPfcCounters(BaseModel): rx_frames: Optional[int] = None tx_frames: Optional[int] = None - warning_fields: ClassVar[dict[str, str]] = { - "rx_frames": "0", - "tx_frames": "0", + warning_fields: ClassVar[dict[str, List[str]]] = { + "rx_frames": ["0"], + "tx_frames": ["0"], } diff --git a/nodescraper/plugins/inband/switch/scale_out_dell/scaleoutdelldata.py b/nodescraper/plugins/inband/switch/scale_out_dell/scaleoutdelldata.py index 5c6af271..d7739f1a 100644 --- a/nodescraper/plugins/inband/switch/scale_out_dell/scaleoutdelldata.py +++ b/nodescraper/plugins/inband/switch/scale_out_dell/scaleoutdelldata.py @@ -44,9 +44,9 @@ class DellArpEntry(BaseModel): type: Optional[str] = None action: Optional[str] = None - error_fields: ClassVar[dict[str, str]] = { - "address": "NOT_NULL", - "hardware_address": "NOT_NULL", + error_fields: ClassVar[dict[str, List[str]]] = { + "address": ["NOT_NULL"], + "hardware_address": ["NOT_NULL"], } @@ -62,8 +62,8 @@ class DellRouteEntry(BaseModel): distance_metric: Optional[str] = None last_update: Optional[str] = None - error_fields: ClassVar[dict[str, str]] = { - "destination": "NOT_NULL", + error_fields: ClassVar[dict[str, List[str]]] = { + "destination": ["NOT_NULL"], } @@ -81,8 +81,8 @@ class DellInterfaceStatus(BaseModel): mtu: Optional[int] = None alternate_name: Optional[str] = None - error_fields: ClassVar[dict[str, str]] = { - "oper": "up", + error_fields: ClassVar[dict[str, List[str]]] = { + "oper": ["up"], } @@ -112,17 +112,17 @@ class DellInterfaceCounters(BaseModel): tx_drp: Optional[int] = None tx_oversize: Optional[int] = None - error_fields: ClassVar[dict[str, str]] = { - "state": "U", - "rx_err": "0", - "rx_oversize": "0", - "tx_err": "0", - "tx_oversize": "0", + error_fields: ClassVar[dict[str, List[str]]] = { + "state": ["U"], + "rx_err": ["0"], + "rx_oversize": ["0"], + "tx_err": ["0"], + "tx_oversize": ["0"], } - warning_fields: ClassVar[dict[str, str]] = { - "rx_drp": "0", - "tx_drp": "0", + warning_fields: ClassVar[dict[str, List[str]]] = { + "rx_drp": ["0"], + "tx_drp": ["0"], } @@ -168,14 +168,14 @@ class DellInterfaceDetailCounters(BaseModel): time_since_counters_last_cleared: Optional[str] = None - error_fields: ClassVar[dict[str, str]] = { - "packets_received_9217_16383_octets": "0", - "packets_transmitted_9217_16383_octets": "0", - "jabbers_received": "0", - "fragments_received": "0", - "undersize_received": "0", - "overruns_received": "0", - "crc_errors_received": "0", + error_fields: ClassVar[dict[str, List[str]]] = { + "packets_received_9217_16383_octets": ["0"], + "packets_transmitted_9217_16383_octets": ["0"], + "jabbers_received": ["0"], + "fragments_received": ["0"], + "undersize_received": ["0"], + "overruns_received": ["0"], + "crc_errors_received": ["0"], } @@ -193,9 +193,9 @@ class DellQueueCounter(BaseModel): drop_pkts: Optional[int] = None drop_bytes: Optional[int] = None - warning_fields: ClassVar[dict[str, str]] = { - "drop_pkts": "0", - "drop_bytes": "0", + warning_fields: ClassVar[dict[str, List[str]]] = { + "drop_pkts": ["0"], + "drop_bytes": ["0"], } @@ -217,15 +217,15 @@ class DellPfcStatistics(BaseModel): pfc6: Optional[int] = None pfc7: Optional[int] = None - warning_fields: ClassVar[dict[str, str]] = { - "pfc0": "0", - "pfc1": "0", - "pfc2": "0", - "pfc3": "0", - "pfc4": "0", - "pfc5": "0", - "pfc6": "0", - "pfc7": "0", + warning_fields: ClassVar[dict[str, List[str]]] = { + "pfc0": ["0"], + "pfc1": ["0"], + "pfc2": ["0"], + "pfc3": ["0"], + "pfc4": ["0"], + "pfc5": ["0"], + "pfc6": ["0"], + "pfc7": ["0"], } @@ -247,17 +247,17 @@ class DellPfcWatchdogQueueStats(BaseModel): rx_last_ok: Optional[int] = None rx_last_drop: Optional[int] = None - warning_fields: ClassVar[dict[str, str]] = { - "storms_detected": "0", - "storms_restored": "0", - "transmitted_ok": "0", - "transmitted_drop": "0", - "received_ok": "0", - "received_drop": "0", - "tx_last_ok": "0", - "tx_last_drop": "0", - "rx_last_ok": "0", - "rx_last_drop": "0", + warning_fields: ClassVar[dict[str, List[str]]] = { + "storms_detected": ["0"], + "storms_restored": ["0"], + "transmitted_ok": ["0"], + "transmitted_drop": ["0"], + "received_ok": ["0"], + "received_drop": ["0"], + "tx_last_ok": ["0"], + "tx_last_drop": ["0"], + "rx_last_ok": ["0"], + "rx_last_drop": ["0"], } diff --git a/nodescraper/plugins/inband/switch/switch_analyzer_base.py b/nodescraper/plugins/inband/switch/switch_analyzer_base.py index bc0a5ba6..4b4d7eae 100644 --- a/nodescraper/plugins/inband/switch/switch_analyzer_base.py +++ b/nodescraper/plugins/inband/switch/switch_analyzer_base.py @@ -100,8 +100,14 @@ def _model_is_analyzed(model_cls: Type[BaseModel]) -> bool: def _values_match(actual: Any, expected: Any) -> bool: - """Compare an actual model value to an expected value.""" + """Compare an actual model value to an expected value. + When ``expected`` is a list or tuple of candidate values the comparison + succeeds if ``actual`` matches any candidate (logical OR). + """ + + if isinstance(expected, (list, tuple)): + return any(_values_match(actual, candidate) for candidate in expected) if isinstance(expected, str) and expected == "NOT_NULL": if actual is None: return False @@ -111,6 +117,17 @@ def _values_match(actual: Any, expected: Any) -> bool: return str(actual) == str(expected) +def _expects_not_null(expected: Any) -> bool: + """Return True if ``expected`` requires a non-null actual value. + + Supports ``expected`` being a list/tuple of candidate values. + """ + + if isinstance(expected, (list, tuple)): + return any(_expects_not_null(item) for item in expected) + return isinstance(expected, str) and expected == "NOT_NULL" + + class SwitchAnalyzerBase(Generic[TSwitchData]): """Shared scaffolding for vendor-specific switch analyzers. @@ -498,7 +515,7 @@ def _check_fields( if not hasattr(model, field_name): continue actual = getattr(model, field_name) - if actual is None and not (isinstance(expected, str) and expected == "NOT_NULL"): + if actual is None and not _expects_not_null(expected): continue if _values_match(actual, expected): continue diff --git a/test/unit/plugin/test_switch_analyzer_base.py b/test/unit/plugin/test_switch_analyzer_base.py index 98a750ab..9685b592 100644 --- a/test/unit/plugin/test_switch_analyzer_base.py +++ b/test/unit/plugin/test_switch_analyzer_base.py @@ -44,6 +44,10 @@ DellPortData, ScaleOutDellDataModel, ) +from nodescraper.plugins.inband.switch.switch_analyzer_base import ( + _expects_not_null, + _values_match, +) @pytest.fixture @@ -169,3 +173,80 @@ def test_analyzer_messages_distinguish_errors_and_warnings(analyzer): result = analyzer.analyze_data(data) assert result.status == ExecutionStatus.ERROR assert result.message.startswith("Arista errors and warnings detected") + + +@pytest.mark.parametrize( + "actual, expected, matches", + [ + ("U", ["U", "S"], True), + ("S", ["U", "S"], True), + ("D", ["U", "S"], False), + ("U", ["U"], True), + ("D", ["U"], False), + ("anything", ["NOT_NULL"], True), + (None, ["NOT_NULL"], False), + ], +) +def test_values_match_supports_list_or(actual, expected, matches): + assert _values_match(actual, expected) is matches + + +def test_expects_not_null_detects_nested_not_null(): + assert _expects_not_null(["U", "NOT_NULL"]) is True + assert _expects_not_null(["U", "S"]) is False + assert _expects_not_null(["NOT_NULL"]) is True + + +def test_error_warning_field_values_are_lists(): + assert DellInterfaceCounters.error_fields["state"] == ["U"] + assert DellInterfaceCounters.warning_fields["rx_drp"] == ["0"] + assert AristaPortStatus.error_fields["link_status"] == ["connected"] + assert all(isinstance(value, list) for value in DellInterfaceCounters.error_fields.values()) + + +def test_dell_state_list_or_passes_on_any_match(dell_analyzer, monkeypatch): + monkeypatch.setitem(DellInterfaceCounters.error_fields, "state", ["U", "S"]) + data = ScaleOutDellDataModel( + port={ + "Eth1/1/1": DellPortData( + interface_counters=DellInterfaceCounters( + state="S", + rx_err=0, + rx_oversize=0, + tx_err=0, + tx_oversize=0, + rx_drp=0, + tx_drp=0, + ), + ), + } + ) + + result = dell_analyzer.analyze_data(data) + + assert result.status != ExecutionStatus.ERROR + assert not any("state" in event.description for event in result.events) + + +def test_dell_state_list_or_errors_when_no_match(dell_analyzer, monkeypatch): + monkeypatch.setitem(DellInterfaceCounters.error_fields, "state", ["U", "S"]) + data = ScaleOutDellDataModel( + port={ + "Eth1/1/1": DellPortData( + interface_counters=DellInterfaceCounters( + state="D", + rx_err=0, + rx_oversize=0, + tx_err=0, + tx_oversize=0, + rx_drp=0, + tx_drp=0, + ), + ), + } + ) + + result = dell_analyzer.analyze_data(data) + + assert result.status == ExecutionStatus.ERROR + assert any("state" in event.description for event in result.events)