diff --git a/nodescraper/cli/helper.py b/nodescraper/cli/helper.py index 1156e369..6eb0f1b6 100644 --- a/nodescraper/cli/helper.py +++ b/nodescraper/cli/helper.py @@ -72,14 +72,14 @@ def get_system_info(args: argparse.Namespace) -> SystemInfo: if args.sys_platform: system_info.platform = args.sys_platform - if args.sys_location: + conn = getattr(args, "connection_config", None) or {} + location_name = args.sys_location or conn.get("sys_location") + if location_name: try: - location = getattr(SystemLocation, args.sys_location) + system_info.location = getattr(SystemLocation, str(location_name).upper()) except Exception as e: raise argparse.ArgumentTypeError("Invalid input for system location") from e - system_info.location = location - return system_info diff --git a/nodescraper/helpers/plugin_execution_target.py b/nodescraper/helpers/plugin_execution_target.py new file mode 100644 index 00000000..eac1c546 --- /dev/null +++ b/nodescraper/helpers/plugin_execution_target.py @@ -0,0 +1,109 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from __future__ import annotations + +from typing import Any, Optional, Union + +from pydantic import BaseModel + +from nodescraper.connection.inband.inbandmanager import InBandConnectionManager +from nodescraper.connection.redfish.redfish_manager import RedfishConnectionManager +from nodescraper.enums import SystemLocation +from nodescraper.interfaces import ConnectionManager, PluginInterface +from nodescraper.models import SystemInfo + + +def _host_from_connection_args(raw: Optional[Union[dict[str, Any], BaseModel]]) -> Optional[str]: + if raw is None: + return None + if isinstance(raw, dict): + for key in ("host", "hostname", "ip"): + value = raw.get(key) + if value: + return str(value) + return None + for key in ("host", "hostname", "ip"): + value = getattr(raw, key, None) + if value is not None and str(value): + return str(value) + return None + + +def format_in_band_target_summary( + system_info: SystemInfo, + connection_configs: Optional[dict[str, Union[dict[str, Any], BaseModel]]] = None, +) -> str: + """Return a short summary of the default in-band execution target.""" + if system_info.location == SystemLocation.REMOTE: + host = _host_from_connection_args((connection_configs or {}).get("InBandConnectionManager")) + if host: + return f"In-band default: remote host via SSH ({host})" + return "In-band default: remote host via SSH" + host_name = system_info.name or "local host" + return f"In-band default: local host ({host_name})" + + +def format_plugin_execution_target( + plugin_class: type[PluginInterface], + *, + system_info: SystemInfo, + connection_manager: Optional[ConnectionManager] = None, + connection_configs: Optional[dict[str, Union[dict[str, Any], BaseModel]]] = None, +) -> Optional[str]: + """Return a one-line description of where a plugin collects data from.""" + connection_type = getattr(plugin_class, "CONNECTION_TYPE", None) + if connection_type is None: + return None + + configs = connection_configs or {} + manager_args = ( + getattr(connection_manager, "connection_args", None) if connection_manager else None + ) + + if connection_type is RedfishConnectionManager or issubclass( + connection_type, RedfishConnectionManager + ): + host = _host_from_connection_args(manager_args) or _host_from_connection_args( + configs.get("RedfishConnectionManager") + ) + if host: + return f"Execution target: BMC via Redfish OOB ({host})" + return "Execution target: BMC via Redfish OOB" + + if connection_type is InBandConnectionManager or issubclass( + connection_type, InBandConnectionManager + ): + if system_info.location == SystemLocation.REMOTE: + host = _host_from_connection_args(manager_args) or _host_from_connection_args( + configs.get("InBandConnectionManager") + ) + if host: + return f"Execution target: remote host via SSH ({host})" + return "Execution target: remote host via SSH" + host_name = system_info.name or "local host" + return f"Execution target: local host ({host_name})" + + return f"Execution target: {connection_type.__name__}" diff --git a/nodescraper/pluginexecutor.py b/nodescraper/pluginexecutor.py index 772f3662..ff36263c 100644 --- a/nodescraper/pluginexecutor.py +++ b/nodescraper/pluginexecutor.py @@ -38,6 +38,10 @@ from nodescraper.base.oobsshdataplugin import OOBSSHDataPlugin from nodescraper.connection.oob_ssh import OobSshConnectionManager from nodescraper.constants import DEFAULT_LOGGER +from nodescraper.helpers.plugin_execution_target import ( + format_in_band_target_summary, + format_plugin_execution_target, +) from nodescraper.interfaces import ConnectionManager, DataPlugin, PluginInterface from nodescraper.interfaces.taskresulthook import TaskResultHook from nodescraper.models import PluginConfig, SystemInfo @@ -123,7 +127,10 @@ def __init__( self.logger.info("System SKU: %s", self.system_info.sku) if self.system_info.platform: self.logger.info("System Platform: %s", self.system_info.platform) - self.logger.info("System location: %s", self.system_info.location) + self.logger.info( + "%s", + format_in_band_target_summary(self.system_info, self.connection_configs), + ) @staticmethod def _deep_merge_plugin_args(existing: dict, incoming: dict) -> dict: @@ -270,6 +277,14 @@ def run_queue(self) -> list[PluginResult]: continue self.logger.info("-" * 50) + execution_target = format_plugin_execution_target( + plugin_class, + system_info=self.system_info, + connection_manager=init_payload.get("connection_manager"), + connection_configs=self.connection_configs, + ) + if execution_target: + self.logger.info("(%s) %s", plugin_name, execution_target) plugin_result = plugin_inst.run(**run_payload) plugin_results.append(plugin_result) for hook in self.plugin_run_result_hooks: diff --git a/nodescraper/plugins/serviceability/mi4xx/mi4xx_analyzer_args.py b/nodescraper/plugins/serviceability/mi4xx/mi4xx_analyzer_args.py index a87cdf7c..2ac11a1b 100644 --- a/nodescraper/plugins/serviceability/mi4xx/mi4xx_analyzer_args.py +++ b/nodescraper/plugins/serviceability/mi4xx/mi4xx_analyzer_args.py @@ -33,19 +33,19 @@ class Mi4xxServiceabilityAnalyzerArgs(ServiceabilityAnalyzerArgs): - """Analysis args for Mi4xxServiceabilityPlugin (AFSE entry point).""" + """Analysis args for Mi4xxServiceabilityPlugin (Hub entry point afse).""" hub_entry_point: str = Field( default="afse", - description="Registered AFSE entry point name (MI4XX service hub).", + description="Registered Hub entry point name (default afse).", ) hub_display_name: Optional[str] = Field( - default="AFSE", + default="Hub", description="Label for analyzer status messages.", ) hub_python_module: Optional[str] = Field( default=None, - description="Not used for MI4XX; AFSE is selected via hub_entry_point afse.", + description="Not used for MI4XX; Hub is selected via hub_entry_point afse.", ) rf_event_log_uri: str = Field( default="/redfish/v1/Systems/Instinct_Accelerators/LogServices/EventLog/Entries", @@ -65,10 +65,10 @@ def resolved_rf_event_log_uri(self) -> str: return str(self.rf_event_log_uri).strip() @model_validator(mode="after") - def _mi4xx_uses_afse(self) -> "Mi4xxServiceabilityAnalyzerArgs": + def _mi4xx_uses_afse_entry_point(self) -> "Mi4xxServiceabilityAnalyzerArgs": if self.hub_python_module: raise ValueError( - "Mi4xxServiceabilityPlugin uses AFSE via hub_entry_point; " + "Mi4xxServiceabilityPlugin uses Hub via hub_entry_point afse; " "hub_python_module is not supported" ) if str(self.hub_entry_point).strip().lower() != "afse": diff --git a/nodescraper/plugins/serviceability/se_adapter.py b/nodescraper/plugins/serviceability/se_adapter.py index 56b0f3b1..a4c8e170 100644 --- a/nodescraper/plugins/serviceability/se_adapter.py +++ b/nodescraper/plugins/serviceability/se_adapter.py @@ -279,11 +279,53 @@ def _optional_int(value: Any) -> Optional[int]: def _entry_point_result_rows(hub_result: dict[str, Any]) -> list[Any]: - """Extract triage rows from the entry-point hub analyze response.""" + """Extract triage.results rows from the entry-point hub analyze response.""" + triage = hub_result.get("triage") + if isinstance(triage, dict): + nested = triage.get("results") + if isinstance(nested, list): + return nested results = hub_result.get("results") return results if isinstance(results, list) else [] +def _entry_point_top_rows(hub_result: dict[str, Any]) -> list[Any]: + """Extract triage.top rows from the entry-point hub analyze response.""" + top = hub_result.get("top") + if isinstance(top, list): + return top + triage = hub_result.get("triage") + if isinstance(triage, dict): + nested = triage.get("top") + if isinstance(nested, list): + return nested + return [] + + +def _service_action_steps_from_row( + row: dict[str, Any], + san: int, + sag: Optional[dict[str, Any]], +) -> list[str]: + sa = row.get("service_action") + if isinstance(sa, dict): + steps = sa.get("steps") + if isinstance(steps, list): + descriptions: list[str] = [] + for step in steps: + if not isinstance(step, dict): + continue + description = step.get("description") + if description is None: + continue + text = str(description).strip() + if text: + descriptions.append(text) + if descriptions: + return descriptions + return service_action_step_descriptions_from_sag(san, sag) + + def _service_action_title_from_row(row: dict[str, Any]) -> Optional[str]: title = row.get("service_action_title") if title is not None: @@ -358,7 +400,7 @@ def _hub_triage_result_from_row( service_action_title=title, service_action_category=category, service_action_severity=sa_severity, - service_action_steps=service_action_step_descriptions_from_sag(san, sag), + service_action_steps=_service_action_steps_from_row(row, san, sag), afid_summary=_afid_summary_from_sag(afid, sag), ) @@ -424,9 +466,10 @@ def format_serviceability_solution_lines(block: ServiceabilityBlock) -> list[str if block.afid_sag_file_version: lines.append(f"AFID_SAG file: {block.afid_sag_file_version}") if block.hub_triage_results: - lines.append("Hub triage results:") - for index, row in enumerate(block.hub_triage_results, start=1): - lines.extend(_format_hub_triage_result_lines(index, row)) + lines.append( + f"{len(block.hub_triage_results)} prioritized recommendation(s); " + "see recommendation tables below" + ) return lines if block.short_service_info: lines.append("short_service_info:") @@ -530,6 +573,7 @@ def serviceability_block_from_entry_point_hub( hub_label: str = "Service hub", rf_event_count: int = 0, afid_sag_path: Optional[str] = None, + hub_analyze_response: Optional[dict[str, Any]] = None, ) -> ServiceabilityBlock: """Build a ServiceabilityBlock from a registered entry-point hub analyze() response.""" hub_name = str(hub_result.get("engine") or hub_label) @@ -606,6 +650,14 @@ def serviceability_block_from_entry_point_hub( parsed = _hub_triage_result_from_row(row, sag) if parsed is not None: triage_results.append(parsed) + + top_results: list[HubTriageResult] = [] + for row in _entry_point_top_rows(hub_result): + if not isinstance(row, dict): + continue + parsed = _hub_triage_result_from_row(row, sag) + if parsed is not None: + top_results.append(parsed) sag_metadata = None afid_sag_file_version = None if sag: @@ -629,5 +681,7 @@ def serviceability_block_from_entry_point_hub( hub_version=hub_version, afid_sag_file_version=afid_sag_file_version, afid_sag_metadata=sag_metadata, + hub_analyze_response=hub_analyze_response, + hub_top_results=top_results, hub_triage_results=triage_results, ) diff --git a/nodescraper/plugins/serviceability/se_models.py b/nodescraper/plugins/serviceability/se_models.py index 94baa835..8969ff66 100644 --- a/nodescraper/plugins/serviceability/se_models.py +++ b/nodescraper/plugins/serviceability/se_models.py @@ -74,6 +74,29 @@ class ServiceabilitySolution(BaseModel): ) +class PrioritizedServiceAction(BaseModel): + """One hub-ranked service action row for tables and API-style consumers.""" + + rank: int + afid: int + location: str + count: int = 1 + service_action_num: int + service_action_title: Optional[str] = None + service_action_category: Optional[str] = None + priority: Optional[int] = None + sa_severity: Optional[int] = None + tier: Optional[int] = None + tier_label: Optional[str] = None + fru: Optional[str] = None + fru_rank: Optional[int] = None + hub_sort_priority: Optional[int] = None + multi_mask: Optional[int] = None + afid_summary: Optional[str] = None + service_action_steps: List[str] = Field(default_factory=list) + serviceable_units: Optional[List[str]] = None + + class HubTriageResult(BaseModel): """One service hub triage row with SAG-enriched action details.""" @@ -130,6 +153,14 @@ class ServiceabilityBlock(BaseModel): "per-unit dict payloads are collapsed, identical messages merged with unit lists)." ), ) + hub_analyze_response: Optional[dict[str, Any]] = Field( + default=None, + description="Unmodified Hub analyze() JSON (triage.top, triage.results, status, pid, revision).", + ) + hub_top_results: List[HubTriageResult] = Field( + default_factory=list, + description="Hub triage.top rows (highest se_sort_priority).", + ) hub_triage_results: List[HubTriageResult] = Field( default_factory=list, description="Full service hub triage rows with SAG-enriched action details.", diff --git a/nodescraper/plugins/serviceability/se_runner.py b/nodescraper/plugins/serviceability/se_runner.py index c30858b5..dc7b74de 100644 --- a/nodescraper/plugins/serviceability/se_runner.py +++ b/nodescraper/plugins/serviceability/se_runner.py @@ -371,6 +371,7 @@ def entry_point_hub_result_from_triage( "status": "error", "error": {"message": error_message or "Service hub analyze failed"}, "results": [], + "top": [], "tier_grouped": {}, } @@ -378,16 +379,23 @@ def entry_point_hub_result_from_triage( result_entries = ( getattr(triage_section, "results", None) if triage_section is not None else None ) + top_entries = getattr(triage_section, "top", None) if triage_section is not None else None results = ( [_resolved_entry_to_hub_row(entry) for entry in result_entries] if isinstance(result_entries, list) else [] ) + top = ( + [_resolved_entry_to_hub_row(entry) for entry in top_entries] + if isinstance(top_entries, list) + else [] + ) return { **base, "status": "ok", "error": None, "results": results, + "top": top, "tier_grouped": _tier_grouped_from_rows(results), } @@ -405,6 +413,7 @@ def _synthetic_error_hub_result( "engine": engine_name, "engine_version": engine_version, "results": [], + "top": [], "tier_grouped": {}, } @@ -428,6 +437,13 @@ def _entry_point_analyze_error(hub_result: dict[str, Any]) -> Optional[str]: return "Service hub analyze failed" +def _hub_analyze_response_from_raw(raw_result: Any) -> dict[str, Any]: + """Return the unmodified Hub analyze JSON payload.""" + if isinstance(raw_result, dict): + return dict(raw_result) + return dataclasses.asdict(raw_result) + + def run_entry_point_hub( *, hub_entry_point: str, @@ -466,7 +482,9 @@ def run_entry_point_hub( if isinstance(raw_result, dict): hub_result = raw_result + hub_analyze_response = _hub_analyze_response_from_raw(raw_result) else: + hub_analyze_response = _hub_analyze_response_from_raw(raw_result) hub_result = entry_point_hub_result_from_triage( raw_result, engine_name=hub_label, @@ -484,6 +502,7 @@ def run_entry_point_hub( hub_label=label, rf_event_count=event_count, afid_sag_path=afid_sag_path, + hub_analyze_response=hub_analyze_response, ) diff --git a/nodescraper/plugins/serviceability/serviceability_api.py b/nodescraper/plugins/serviceability/serviceability_api.py new file mode 100644 index 00000000..2cbfbc29 --- /dev/null +++ b/nodescraper/plugins/serviceability/serviceability_api.py @@ -0,0 +1,165 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from __future__ import annotations + +from typing import Any + +from .se_models import ( + HubTriageResult, + PrioritizedServiceAction, + ServiceabilityBlock, + ServiceabilitySolution, +) + + +def service_action_identity(action: PrioritizedServiceAction) -> tuple[int, str, int]: + """Return the Hub row identity used to match top vs results entries.""" + return (action.afid, action.location, action.service_action_num) + + +def _hub_triage_identity(row: HubTriageResult) -> tuple[int, str, int]: + return (row.afid, row.location, row.service_action_num) + + +def _triage_row_to_action(rank: int, row: HubTriageResult) -> PrioritizedServiceAction: + return PrioritizedServiceAction( + rank=rank, + afid=row.afid, + location=row.location, + count=row.count, + service_action_num=row.service_action_num, + service_action_title=row.service_action_title, + service_action_category=row.service_action_category, + priority=row.priority, + sa_severity=row.sa_severity, + tier=row.tier, + tier_label=row.tier_label, + fru=row.fru, + fru_rank=row.fru_rank, + hub_sort_priority=row.hub_sort_priority, + multi_mask=row.multi_mask, + afid_summary=row.afid_summary, + service_action_steps=list(row.service_action_steps), + ) + + +def _solution_to_action(rank: int, solution: ServiceabilitySolution) -> PrioritizedServiceAction: + return PrioritizedServiceAction( + rank=rank, + afid=solution.afid, + location=solution.serviceable_unit[0] if solution.serviceable_unit else "", + count=1, + service_action_num=solution.service_action_num, + service_action_title=solution.service_action_title, + service_action_category=None, + priority=None, + sa_severity=None, + tier=None, + tier_label=solution.service_action_tier, + fru=None, + fru_rank=None, + hub_sort_priority=None, + multi_mask=None, + afid_summary=solution.afid_summary, + service_action_steps=[], + serviceable_units=list(solution.serviceable_unit), + ) + + +def build_top_service_actions(block: ServiceabilityBlock) -> list[PrioritizedServiceAction]: + """Return Hub triage.top service actions with ranks from triage.results order.""" + if block.hub_top_results: + rank_by_identity = { + _hub_triage_identity(row): rank + for rank, row in enumerate(block.hub_triage_results, start=1) + } + top_actions: list[PrioritizedServiceAction] = [] + for index, row in enumerate(block.hub_top_results, start=1): + rank = rank_by_identity.get(_hub_triage_identity(row), index) + top_actions.append(_triage_row_to_action(rank, row)) + return top_actions + if block.hub_triage_results: + return [_triage_row_to_action(1, block.hub_triage_results[0])] + if block.solution: + return [_solution_to_action(1, block.solution[0])] + return [] + + +def build_prioritized_service_actions( + block: ServiceabilityBlock, +) -> list[PrioritizedServiceAction]: + """Return hub-ranked service actions from triage.results order.""" + if block.hub_triage_results: + return [ + _triage_row_to_action(rank, row) + for rank, row in enumerate(block.hub_triage_results, start=1) + ] + return [ + _solution_to_action(rank, solution) for rank, solution in enumerate(block.solution, start=1) + ] + + +def split_recommendation_actions( + block: ServiceabilityBlock, +) -> tuple[list[PrioritizedServiceAction], list[PrioritizedServiceAction]]: + """Split Hub triage.top service actions from lower-priority triage.results rows.""" + prioritized = build_prioritized_service_actions(block) + top = build_top_service_actions(block) + if not prioritized: + return top, [] + if not top: + return [prioritized[0]], prioritized[1:] + + top_sort_priorities = { + action.hub_sort_priority for action in top if action.hub_sort_priority is not None + } + if top_sort_priorities: + additional = [ + action for action in prioritized if action.hub_sort_priority not in top_sort_priorities + ] + return top, additional + + top_identities = {service_action_identity(action) for action in top} + additional = [ + action for action in prioritized if service_action_identity(action) not in top_identities + ] + return top, additional + + +def prepare_serviceability_block_for_export( + block: ServiceabilityBlock, +) -> ServiceabilityBlock: + """Return a JSON-safe serviceability block without duplicate hub triage rows.""" + return block.model_copy(update={"hub_triage_results": [], "hub_top_results": []}) + + +def export_serviceability_json(block: ServiceabilityBlock) -> dict[str, Any]: + """Serialize serviceability.json with raw Hub output and no duplicate triage rows.""" + exported = prepare_serviceability_block_for_export(block) + return exported.model_dump( + mode="json", + exclude={"hub_triage_results", "hub_top_results"}, + ) diff --git a/nodescraper/plugins/serviceability/serviceability_data.py b/nodescraper/plugins/serviceability/serviceability_data.py index 69f81fab..b77ad3f5 100644 --- a/nodescraper/plugins/serviceability/serviceability_data.py +++ b/nodescraper/plugins/serviceability/serviceability_data.py @@ -152,9 +152,11 @@ def log_model(self, log_path: str) -> None: json.dump(self.cper_data, f, indent=2) if self.serviceability is not None: serviceability_path = os.path.join(log_path, "serviceability.json") + from .serviceability_api import export_serviceability_json + with open(serviceability_path, "w", encoding="utf-8") as f: json.dump( - self.serviceability.model_dump(mode="json"), + export_serviceability_json(self.serviceability), f, indent=2, ) diff --git a/nodescraper/plugins/serviceability/serviceability_recommendations_table.py b/nodescraper/plugins/serviceability/serviceability_recommendations_table.py new file mode 100644 index 00000000..528d95cc --- /dev/null +++ b/nodescraper/plugins/serviceability/serviceability_recommendations_table.py @@ -0,0 +1,287 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from __future__ import annotations + +import sys +from textwrap import wrap +from typing import Any, Optional + +from nodescraper.models import PluginResult + +from .se_models import PrioritizedServiceAction, ServiceabilityBlock +from .serviceability_api import split_recommendation_actions + +RECOMMENDATION_TABLE_HEADERS: tuple[str, ...] = ( + "Rank", + "Priority", + "SA Sev", + "Tier", + "Units", + "Service Action", + "Steps", +) + +PRIMARY_RECOMMENDATION_TITLE = "Top Hub service action" +ADDITIONAL_RECOMMENDATIONS_TITLE = "Additional Hub service actions" +ADDITIONAL_RECOMMENDATIONS_NOTE = ( + "Lower-priority follow-on actions if the top service action does not resolve the issue." +) + +RECOMMENDATION_TABLE_MAX_WIDTHS: dict[str, int] = { + "Rank": 4, + "Priority": 8, + "SA Sev": 6, + "Tier": 10, + "Units": 28, + "Service Action": 40, + "Steps": 96, +} + + +def _recommendation_section_title( + title: str, + actions: list[PrioritizedServiceAction], +) -> str: + if not actions: + return title + ranks = [action.rank for action in actions if action.rank] + if len(actions) == 1 and ranks: + return f"{title} (rank {ranks[0]})" + if ranks: + return ( + f"{title} (ranks {min(ranks)}-{max(ranks)}, " + f"{len(actions)} item{'s' if len(actions) != 1 else ''})" + ) + return f"{title} ({len(actions)} item{'s' if len(actions) != 1 else ''})" + + +def _short_serviceable_unit(location: str) -> str: + text = str(location).strip().rstrip("/") + if not text: + return "" + return text.rsplit("/", 1)[-1] + + +def _recommendation_units_cell(action: PrioritizedServiceAction) -> str: + units = list(action.serviceable_units or []) + if not units and action.location: + units = [action.location] + return ", ".join(_short_serviceable_unit(unit) for unit in units if unit) + + +def _recommendation_service_action_cell(action: PrioritizedServiceAction) -> str: + title = (action.service_action_title or "").strip() + if title: + return f"{action.afid}: {title}" + return f"{action.afid}: SA {action.service_action_num}" + + +def _recommendation_steps_cell(action: PrioritizedServiceAction) -> str: + steps = [str(step).strip() for step in action.service_action_steps if str(step).strip()] + if not steps: + return "" + return "; ".join(f"{index}: {step}" for index, step in enumerate(steps)) + + +def _recommendation_table_row(action: PrioritizedServiceAction) -> list[str]: + priority = "" if action.priority is None else str(action.priority) + sa_severity = "" if action.sa_severity is None else str(action.sa_severity) + tier = (action.tier_label or "").strip() + return [ + str(action.rank), + priority, + sa_severity, + tier, + _recommendation_units_cell(action), + _recommendation_service_action_cell(action), + _recommendation_steps_cell(action), + ] + + +def render_recommendations_table( + actions: list[PrioritizedServiceAction], + *, + headers: tuple[str, ...] = RECOMMENDATION_TABLE_HEADERS, + max_widths: Optional[dict[str, int]] = None, +) -> str: + """Render prioritized Hub recommendations as a bordered ASCII table.""" + if max_widths is None: + max_widths = dict(RECOMMENDATION_TABLE_MAX_WIDTHS) + rows = [_recommendation_table_row(action) for action in actions] + return _gen_str_table(list(headers), rows, max_widths=max_widths) + + +def render_recommendations_section( + title: str, + actions: list[PrioritizedServiceAction], + *, + note: Optional[str] = None, +) -> str: + """Render a titled recommendations block with an optional explanatory note.""" + if not actions: + return "" + lines = [title] + if note: + lines.append(note) + lines.append(render_recommendations_table(actions)) + return "\n".join(lines) + + +def _serviceability_block_from_plugin_result( + plugin_result: PluginResult, +) -> Optional[ServiceabilityBlock]: + """Return a ServiceabilityBlock when a plugin result includes hub recommendation data.""" + result_data = plugin_result.result_data + if result_data is None: + return None + + system_data: Any + if isinstance(result_data, dict): + system_data = result_data.get("system_data") + else: + system_data = getattr(result_data, "system_data", None) + if system_data is None: + return None + + serviceability: Any + if isinstance(system_data, dict): + serviceability = system_data.get("serviceability") + else: + serviceability = getattr(system_data, "serviceability", None) + if serviceability is None: + return None + + if isinstance(serviceability, dict): + serviceability = ServiceabilityBlock.model_validate(serviceability) + if not (serviceability.hub_triage_results or serviceability.hub_top_results): + return None + return serviceability + + +def render_serviceability_recommendation_tables_for_plugin_results( + plugin_results: list[PluginResult], +) -> str: + """Render Hub recommendation tables for all plugins that produced serviceability output.""" + sections: list[str] = [] + for plugin_result in plugin_results: + block = _serviceability_block_from_plugin_result(plugin_result) + if block is None: + continue + rendered = render_serviceability_recommendation_tables(block) + if rendered: + sections.append(rendered) + return "\n\n".join(sections) + + +def render_serviceability_recommendation_tables(block: ServiceabilityBlock) -> str: + """Render primary and additional Hub recommendation tables.""" + primary, additional = split_recommendation_actions(block) + sections: list[str] = [] + primary_section = render_recommendations_section( + _recommendation_section_title(PRIMARY_RECOMMENDATION_TITLE, primary), + primary, + ) + if primary_section: + sections.append(primary_section) + additional_section = render_recommendations_section( + _recommendation_section_title(ADDITIONAL_RECOMMENDATIONS_TITLE, additional), + additional, + note=ADDITIONAL_RECOMMENDATIONS_NOTE, + ) + if additional_section: + sections.append(additional_section) + if not sections: + return "" + return "\n\n".join(sections) + + +def emit_serviceability_recommendation_tables(block: ServiceabilityBlock) -> None: + """Print Hub recommendation tables to stdout.""" + output = render_serviceability_recommendation_tables(block) + if not output: + return + sys.stdout.write(f"\n{output}\n") + + +def _gen_str_table( + headers: list[str], + rows: list[list[str]], + max_widths: Optional[dict[str, int]] = None, +) -> str: + max_widths = max_widths or {} + norm_rows: list[list[str]] = [[str(cell) for cell in row] for row in rows] + ncols = len(headers) + + raw_widths: list[int] = [len(header) for header in headers] + for norm_row in norm_rows: + for index, cell in enumerate(norm_row): + for part in cell.splitlines() or [""]: + if len(part) > raw_widths[index]: + raw_widths[index] = len(part) + + target_widths: list[int] = [] + for index, header in enumerate(headers): + cap = max_widths.get(header) + if cap is None: + target_widths.append(raw_widths[index]) + else: + target_widths.append(max(len(header), min(raw_widths[index], cap))) + + wrapped_rows: list[list[list[str]]] = [] + for norm_row in norm_rows: + wrapped_cells: list[list[str]] = [] + for index, cell in enumerate(norm_row): + cell_lines: list[str] = [] + for paragraph in cell.splitlines() or [""]: + cell_lines.extend(wrap(paragraph, width=target_widths[index]) or [""]) + wrapped_cells.append(cell_lines) + wrapped_rows.append(wrapped_cells) + + col_widths: list[int] = [] + for index in range(ncols): + widest_line = len(headers[index]) + for wrapped_row in wrapped_rows: + for line in wrapped_row[index]: + if len(line) > widest_line: + widest_line = len(line) + col_widths.append(widest_line) + + border = "+" + "+".join("-" * (width + 2) for width in col_widths) + "+" + + def render_physical_row(parts: list[str]) -> str: + return "| " + " | ".join(part.ljust(width) for part, width in zip(parts, col_widths)) + " |" + + table_lines = [border, render_physical_row(headers), border] + for wrapped_row in wrapped_rows: + height = max(len(cell_lines) for cell_lines in wrapped_row) + for line_index in range(height): + parts = [ + wrapped_row[column][line_index] if line_index < len(wrapped_row[column]) else "" + for column in range(ncols) + ] + table_lines.append(render_physical_row(parts)) + table_lines.append(border) + return "\n".join(table_lines) diff --git a/nodescraper/resultcollators/tablesummary.py b/nodescraper/resultcollators/tablesummary.py index 49f816b2..cde7dc25 100644 --- a/nodescraper/resultcollators/tablesummary.py +++ b/nodescraper/resultcollators/tablesummary.py @@ -28,6 +28,9 @@ from nodescraper.interfaces import PluginResultCollator from nodescraper.models import PluginResult, TaskResult +from nodescraper.plugins.serviceability.serviceability_recommendations_table import ( + render_serviceability_recommendation_tables_for_plugin_results, +) class TableSummary(PluginResultCollator): @@ -119,7 +122,8 @@ def render_physical_row(parts: list[str]) -> str: table_lines.append(border) return "\n".join(table_lines) - tables = "" + table_sections: list[str] = [] + if connection_results: conn_rows: list[list[Optional[str]]] = [] for connection_result in connection_results: @@ -136,7 +140,13 @@ def render_physical_row(parts: list[str]) -> str: conn_rows, max_widths={"Connection": 32, "Status": 20, "Message": 80}, ) - tables += f"\n\n{table}" + table_sections.append(table) + + serviceability_tables = render_serviceability_recommendation_tables_for_plugin_results( + plugin_results + ) + if serviceability_tables: + table_sections.append(serviceability_tables) if plugin_results: plug_rows: list[list[Optional[str]]] = [] @@ -153,7 +163,8 @@ def render_physical_row(parts: list[str]) -> str: plug_rows, max_widths={"Plugin": 32, "Status": 20, "Message": 80}, ) - tables += f"\n\n{table}" + table_sections.append(table) - if tables: + if table_sections: + tables = "\n\n".join(f"\n{section}" for section in table_sections) self.logger.info("%s\n", tables) diff --git a/test/unit/framework/test_plugin_execution_target.py b/test/unit/framework/test_plugin_execution_target.py new file mode 100644 index 00000000..6a29bba0 --- /dev/null +++ b/test/unit/framework/test_plugin_execution_target.py @@ -0,0 +1,90 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from framework.common.shared_utils import DummyDataModel + +from nodescraper.base.inbanddataplugin import InBandDataPlugin +from nodescraper.base.oobanddataplugin import OOBandDataPlugin +from nodescraper.enums import SystemLocation +from nodescraper.helpers.plugin_execution_target import ( + format_in_band_target_summary, + format_plugin_execution_target, +) +from nodescraper.models import SystemInfo + + +class _DummyOobPlugin(OOBandDataPlugin): + DATA_MODEL = DummyDataModel + + +class _DummyInBandPlugin(InBandDataPlugin): + DATA_MODEL = DummyDataModel + + +def test_format_in_band_target_summary_local(): + summary = format_in_band_target_summary(SystemInfo(name="workstation01")) + assert summary == "In-band default: local host (workstation01)" + + +def test_format_in_band_target_summary_remote(): + summary = format_in_band_target_summary( + SystemInfo(name="workstation01", location=SystemLocation.REMOTE), + connection_configs={ + "InBandConnectionManager": { + "hostname": "ctheliosp-1b112-b34-1.mnb.dcgpu", + } + }, + ) + assert summary == "In-band default: remote host via SSH (ctheliosp-1b112-b34-1.mnb.dcgpu)" + + +def test_format_plugin_execution_target_redfish(): + target = format_plugin_execution_target( + _DummyOobPlugin, + system_info=SystemInfo(name="workstation01"), + connection_configs={ + "RedfishConnectionManager": {"host": "bmc.example.com"}, + }, + ) + assert target == "Execution target: BMC via Redfish OOB (bmc.example.com)" + + +def test_format_plugin_execution_target_inband_local(): + target = format_plugin_execution_target( + _DummyInBandPlugin, + system_info=SystemInfo(name="workstation01", location=SystemLocation.LOCAL), + ) + assert target == "Execution target: local host (workstation01)" + + +def test_format_plugin_execution_target_inband_remote(): + target = format_plugin_execution_target( + _DummyInBandPlugin, + system_info=SystemInfo(name="workstation01", location=SystemLocation.REMOTE), + connection_configs={ + "InBandConnectionManager": {"hostname": "sut.example.com"}, + }, + ) + assert target == "Execution target: remote host via SSH (sut.example.com)" diff --git a/test/unit/framework/test_tablesummary.py b/test/unit/framework/test_tablesummary.py new file mode 100644 index 00000000..6dd07234 --- /dev/null +++ b/test/unit/framework/test_tablesummary.py @@ -0,0 +1,95 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +import logging + +from serviceability_dummy_data import DUMMY_AFID_A, DUMMY_UNIT_A + +from nodescraper.enums import ExecutionStatus +from nodescraper.models import DataPluginResult, PluginResult, TaskResult +from nodescraper.plugins.serviceability.se_models import ( + HubTriageResult, + ServiceabilityBlock, +) +from nodescraper.plugins.serviceability.serviceability_data import ( + ServiceabilityDataModel, +) +from nodescraper.resultcollators.tablesummary import TableSummary + + +def test_tablesummary_prints_connection_serviceability_then_plugin(caplog): + block = ServiceabilityBlock( + hub_top_results=[ + HubTriageResult( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + service_action_num=11018, + service_action_title="Check and Retry FW Bundle", + priority=1, + sa_severity=20, + tier_label="Secondary", + hub_sort_priority=1000, + ) + ], + hub_triage_results=[ + HubTriageResult( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + service_action_num=11018, + service_action_title="Check and Retry FW Bundle", + priority=1, + sa_severity=20, + tier_label="Secondary", + hub_sort_priority=1000, + ) + ], + ) + plugin_results = [ + PluginResult( + status=ExecutionStatus.OK, + source="Mi4xxServiceabilityPlugin", + message="Plugin tasks completed successfully", + result_data=DataPluginResult( + system_data=ServiceabilityDataModel(serviceability=block), + ), + ) + ] + connection_results = [ + TaskResult( + task="RedfishConnectionManager", + status=ExecutionStatus.OK, + message="task completed successfully", + ) + ] + + logger = logging.getLogger("test_tablesummary") + caplog.set_level(logging.INFO, logger="test_tablesummary") + TableSummary(logger=logger).collate_results(plugin_results, connection_results) + + output = caplog.text + connection_pos = output.index("| Connection") + serviceability_pos = output.index("Top Hub service action") + plugin_pos = output.index("| Plugin") + assert connection_pos < serviceability_pos < plugin_pos diff --git a/test/unit/plugin/test_mi4xx_plugin.py b/test/unit/plugin/test_mi4xx_plugin.py index 5b654e3f..25964cd6 100644 --- a/test/unit/plugin/test_mi4xx_plugin.py +++ b/test/unit/plugin/test_mi4xx_plugin.py @@ -183,7 +183,7 @@ def test_mi4xx_serviceability_plugin_wiring(): def test_mi4xx_analyzer_args_defaults_to_afse(): args = Mi4xxServiceabilityAnalyzerArgs() assert args.hub_entry_point == "afse" - assert args.hub_display_name == "AFSE" + assert args.hub_display_name == "Hub" assert args.resolved_hub_entry_point() == "afse" assert args.skip_hub is False @@ -318,7 +318,8 @@ def test_serviceability_hub_analyzer_runs_entry_point_hub(system_info, tmp_path) Mi4xxServiceabilityAnalyzerArgs(afid_sag_path=str(sag)), ) assert task.status == ExecutionStatus.OK - assert "afse" in task.message.lower() + assert task.message.startswith("Hub:") + assert DUMMY_HUB_VERSION_ENTRY in task.message def test_mi4xx_analyzer_appends_afid_sag_metadata_artifact(system_info, tmp_path): diff --git a/test/unit/plugin/test_se_runner.py b/test/unit/plugin/test_se_runner.py index c972a27a..d355d814 100644 --- a/test/unit/plugin/test_se_runner.py +++ b/test/unit/plugin/test_se_runner.py @@ -307,9 +307,10 @@ def test_serviceability_block_from_entry_point_hub_uses_sag_labels(tmp_path): assert triage.service_action_steps assert triage.service_action_category == "Reflash" lines = format_serviceability_solution_lines(block) - assert "Hub triage results:" in lines - assert "priority=" in "\n".join(lines) - assert "step 0:" in "\n".join(lines) + assert any( + "prioritized recommendation(s); see recommendation tables below" in line for line in lines + ) + assert "Hub triage results:" not in lines def test_serviceability_block_from_service_result(): diff --git a/test/unit/plugin/test_serviceability_api.py b/test/unit/plugin/test_serviceability_api.py new file mode 100644 index 00000000..cb6f15a3 --- /dev/null +++ b/test/unit/plugin/test_serviceability_api.py @@ -0,0 +1,80 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from serviceability_dummy_data import DUMMY_AFID_A, DUMMY_UNIT_A + +from nodescraper.plugins.serviceability.se_models import ( + AfidEvent, + HubTriageResult, + ServiceabilityBlock, +) +from nodescraper.plugins.serviceability.serviceability_api import ( + export_serviceability_json, +) + + +def test_export_serviceability_json_keeps_raw_hub_and_drops_duplicate_rows(): + block = ServiceabilityBlock( + afid_events=[ + AfidEvent( + afid=DUMMY_AFID_A, serviceable_unit="Instinct_EAM_0", time="2025-01-01T00:00:00Z" + ) + ], + hub_analyze_response={ + "schema_version": "1.0", + "status": "ok", + "triage": { + "top": [{"afid": DUMMY_AFID_A, "location": DUMMY_UNIT_A, "se_sort_priority": 1000}], + "results": [ + {"afid": DUMMY_AFID_A, "location": DUMMY_UNIT_A, "se_sort_priority": 1000} + ], + "multi_afid_summary": [], + }, + "pid": "SAG-00000", + "revision": "1.0.0", + }, + hub_top_results=[ + HubTriageResult( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + service_action_num=199, + hub_sort_priority=1000, + ) + ], + hub_triage_results=[ + HubTriageResult( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + service_action_num=199, + hub_sort_priority=1000, + ) + ], + ) + + payload = export_serviceability_json(block) + + assert payload["hub_analyze_response"]["triage"]["top"][0]["se_sort_priority"] == 1000 + assert "hub_top_results" not in payload + assert "hub_triage_results" not in payload diff --git a/test/unit/plugin/test_serviceability_recommendations_table.py b/test/unit/plugin/test_serviceability_recommendations_table.py new file mode 100644 index 00000000..f867a66c --- /dev/null +++ b/test/unit/plugin/test_serviceability_recommendations_table.py @@ -0,0 +1,258 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from serviceability_dummy_data import ( + DUMMY_AFID_A, + DUMMY_AFID_B, + DUMMY_SERVICE_ACTION_NUM, + DUMMY_TIER_CRITICAL, + DUMMY_TIER_LABEL, + DUMMY_UNIT_A, + DUMMY_UNIT_B, +) + +from nodescraper.enums import ExecutionStatus +from nodescraper.models import DataPluginResult, PluginResult +from nodescraper.plugins.serviceability.se_models import ( + HubTriageResult, + ServiceabilityBlock, +) +from nodescraper.plugins.serviceability.serviceability_api import ( + build_top_service_actions, + split_recommendation_actions, +) +from nodescraper.plugins.serviceability.serviceability_data import ( + ServiceabilityDataModel, +) +from nodescraper.plugins.serviceability.serviceability_recommendations_table import ( + emit_serviceability_recommendation_tables, + render_serviceability_recommendation_tables, + render_serviceability_recommendation_tables_for_plugin_results, +) + + +def _row( + *, + afid: int, + location: str, + sort_priority: int, + priority: int, + title: str, +) -> HubTriageResult: + return HubTriageResult( + afid=afid, + location=location, + service_action_num=DUMMY_SERVICE_ACTION_NUM, + service_action_title=title, + priority=priority, + sa_severity=20, + tier_label=DUMMY_TIER_CRITICAL if priority == 1 else DUMMY_TIER_LABEL, + hub_sort_priority=sort_priority, + ) + + +def test_split_recommendation_rows_uses_hub_top(): + block = ServiceabilityBlock( + hub_top_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ) + ], + hub_triage_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ), + _row( + afid=DUMMY_AFID_B, + location=DUMMY_UNIT_B, + sort_priority=2000, + priority=20, + title="Update FW", + ), + ], + ) + + top, additional = split_recommendation_actions(block) + + assert len(top) == 1 + assert top[0].afid == DUMMY_AFID_A + assert len(additional) == 1 + assert additional[0].afid == DUMMY_AFID_B + + +def test_render_serviceability_recommendation_tables_splits_sections(): + block = ServiceabilityBlock( + hub_top_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ) + ], + hub_triage_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ), + _row( + afid=DUMMY_AFID_B, + location=DUMMY_UNIT_B, + sort_priority=2000, + priority=20, + title="Update FW", + ), + ], + ) + + output = render_serviceability_recommendation_tables(block) + + assert "Top Hub service action (rank 1)" in output + assert "Additional Hub service actions (rank 2)" in output + assert f"{DUMMY_AFID_A}: Contact Support" in output + assert f"{DUMMY_AFID_B}: Update FW" in output + assert output.index("Top Hub service action") < output.index("Additional Hub service actions") + + +def test_build_top_service_actions_includes_tied_entries(): + block = ServiceabilityBlock( + hub_top_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Replace Unit", + ), + _row( + afid=DUMMY_AFID_B, + location=DUMMY_UNIT_B, + sort_priority=1000, + priority=1, + title="Replace Unit", + ), + ], + hub_triage_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Replace Unit", + ), + _row( + afid=DUMMY_AFID_B, + location=DUMMY_UNIT_B, + sort_priority=1000, + priority=1, + title="Replace Unit", + ), + ], + ) + + top = build_top_service_actions(block) + + assert len(top) == 2 + assert {action.afid for action in top} == {DUMMY_AFID_A, DUMMY_AFID_B} + + +def test_render_serviceability_recommendation_tables_for_plugin_results(): + block = ServiceabilityBlock( + hub_top_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ) + ], + hub_triage_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ), + ], + ) + plugin_results = [ + PluginResult( + status=ExecutionStatus.OK, + source="Mi4xxServiceabilityPlugin", + message="ok", + result_data=DataPluginResult( + system_data=ServiceabilityDataModel(serviceability=block), + ), + ) + ] + + output = render_serviceability_recommendation_tables_for_plugin_results(plugin_results) + + assert "Top Hub service action" in output + assert "Contact Support" in output + + +def test_emit_serviceability_recommendation_tables_writes_stdout(capsys): + block = ServiceabilityBlock( + hub_top_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ) + ], + hub_triage_results=[ + _row( + afid=DUMMY_AFID_A, + location=DUMMY_UNIT_A, + sort_priority=1000, + priority=1, + title="Contact Support", + ), + ], + ) + + emit_serviceability_recommendation_tables(block) + captured = capsys.readouterr() + + assert "Top Hub service action" in captured.out + assert "Rank" in captured.out + assert "Contact Support" in captured.out