Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion nodescraper/base/inbandcollectortask.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ def _run_sut_cmd(
timeout: int = 300,
strip: bool = True,
log_artifact: bool = True,
html_view: Optional[bool] = None,
) -> CommandArtifact:
"""
Run a command on the SUT and return the result.
Expand All @@ -90,14 +91,18 @@ def _run_sut_cmd(
timeout (int, optional): command timeout in seconds. Defaults to 300.
strip (bool, optional): whether output should be stripped. Defaults to True.
log_artifact (bool, optional): whether we should log the command result. Defaults to True.
html_view (Optional[bool], optional): whether to include this command in HTML
artifacts. When omitted, uses collection_args.html_view.

Returns:
CommandArtifact: The result of the command execution, which includes stdout, stderr, and exit code.
"""
command_res = self.connection.run_command(
command=command, sudo=sudo, timeout=timeout, strip=strip
)
if log_artifact:
effective_html_view = self._effective_html_view(html_view)
if log_artifact or effective_html_view:
command_res.log_html = effective_html_view
self.result.artifacts.append(command_res)

return command_res
Expand Down
28 changes: 26 additions & 2 deletions nodescraper/base/redfishcollectortask.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,23 @@ def _run_redfish_get(
self,
path: str,
log_artifact: bool = True,
html_view: Optional[bool] = None,
) -> RedfishGetResult:
"""Run a Redfish GET request and return the result.

Args:
path: Redfish URI path
log_artifact: If True, append the result to self.result.artifacts.
html_view: When set, controls HTML artifact output. When omitted, uses
collection_args.html_view.

Returns:
RedfishGetResult: path, success, data (or error), status_code.
"""
res = self.connection.run_get(path)
if log_artifact:
effective_html_view = self._effective_html_view(html_view)
if log_artifact or effective_html_view:
res.log_html = effective_html_view
self.result.artifacts.append(res)
return res

Expand All @@ -88,6 +93,7 @@ def _run_redfish_get_paged(
path: str,
max_pages: int = 200,
log_artifact: bool = True,
html_view: Optional[bool] = None,
) -> RedfishGetResult:
"""
Run a Redfish GET and follow Members@odata.nextLink pagination, merging all pages into a single response.
Expand All @@ -96,11 +102,29 @@ def _run_redfish_get_paged(
path (str): Redfish URI path.
max_pages (int, optional): safety cap on the number of pages to follow. Defaults to 200.
log_artifact (bool, optional): whether we should log the merged result. Defaults to True.
html_view (Optional[bool], optional): whether to include this request in HTML artifacts.
When omitted, uses collection_args.html_view.

Returns:
RedfishGetResult: path, success, merged data (or error), status_code.
"""
res = self.connection.run_get_paged(path, max_pages=max_pages)
if log_artifact:
effective_html_view = self._effective_html_view(html_view)
if log_artifact or effective_html_view:
res.log_html = effective_html_view
self.result.artifacts.append(res)
return res

def _append_redfish_artifact(
self,
res: RedfishGetResult,
*,
log_artifact: bool = True,
html_view: Optional[bool] = None,
) -> RedfishGetResult:
"""Append a Redfish GET result to task artifacts with log flags applied."""
effective_html_view = self._effective_html_view(html_view)
if log_artifact or effective_html_view:
res.log_html = effective_html_view
self.result.artifacts.append(res)
return res
231 changes: 231 additions & 0 deletions nodescraper/command_artifact_html.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
###############################################################################
#
# 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 html

COMMAND_ARTIFACTS_BASENAME = "command_artifacts"

_HTML_HEAD = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Command Artifacts</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--panel-hover: #1c2330;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #58a6ff;
--ok-bg: #1a3326; --ok-fg: #4ac26b;
--fail-bg: #3a1d1d; --fail-fg: #f85149;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
line-height: 1.5;
}
header {
position: sticky; top: 0; z-index: 10;
background: var(--panel);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
}
header h1 { margin: 0 0 4px; font-size: 18px; }
header .sub { color: var(--muted); font-size: 13px; word-break: break-all; }
.controls {
display: flex; gap: 8px; align-items: center; margin-top: 12px; flex-wrap: wrap;
}
.controls input {
flex: 1 1 280px; min-width: 200px;
background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: 6px;
padding: 8px 12px; font-size: 14px;
}
.controls button {
background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: 6px;
padding: 8px 12px; font-size: 13px; cursor: pointer;
}
.controls button:hover { background: var(--panel-hover); border-color: var(--accent); }
main { padding: 16px 24px 64px; max-width: 1400px; margin: 0 auto; }
details.cmd {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 10px;
overflow: hidden;
}
details.cmd summary {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px; cursor: pointer; list-style: none;
user-select: none;
}
details.cmd summary::-webkit-details-marker { display: none; }
details.cmd summary:hover { background: var(--panel-hover); }
.chevron { color: var(--muted); transition: transform .15s ease; font-size: 12px; }
details.cmd[open] .chevron { transform: rotate(90deg); }
code.title {
flex: 1;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 13.5px; color: var(--accent); word-break: break-all;
}
.badge {
flex: 0 0 auto;
font-size: 11px; font-weight: 600;
padding: 3px 8px; border-radius: 12px;
font-family: "SFMono-Regular", Consolas, monospace;
}
.badge.ok { background: var(--ok-bg); color: var(--ok-fg); }
.badge.fail { background: var(--fail-bg); color: var(--fail-fg); }
.body { border-top: 1px solid var(--border); }
pre {
margin: 0; padding: 14px 16px;
overflow-x: auto;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12.5px; line-height: 1.45;
white-space: pre; color: var(--text);
}
pre.empty { color: var(--muted); font-style: italic; }
.stderr-label {
padding: 6px 16px 0; color: var(--fail-fg);
font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px;
}
pre.stderr { color: var(--fail-fg); }
.no-results { color: var(--muted); padding: 24px; text-align: center; display: none; }
</style>
</head>
<body>
"""

_HEADER_TEMPLATE = """<header>
<h1>Command Artifacts</h1>
<div class="sub">{count} commands &middot; {title}</div>
<div class="controls">
<input id="filter" type="text" placeholder="Filter commands&hellip;" autocomplete="off">
<button id="expandAll" type="button">Expand all</button>
<button id="collapseAll" type="button">Collapse all</button>
</div>
</header>
<main id="list">
"""

_CARD_TEMPLATE = """ <details class="cmd">
<summary>
<span class="chevron">&#9656;</span>
<code class="title">{command}</code>
<span class="badge {badge_cls}">exit {exit_code}</span>
</summary>
<div class="body">
{stdout_block}
{stderr_block}
</div>
</details>"""

_HTML_TAIL = """
<div class="no-results" id="noResults">No commands match your filter.</div>
</main>
<script>
const filter = document.getElementById('filter');
const items = Array.from(document.querySelectorAll('details.cmd'));
const noResults = document.getElementById('noResults');

filter.addEventListener('input', () => {
const q = filter.value.trim().toLowerCase();
let visible = 0;
items.forEach(d => {
const title = d.querySelector('.title').textContent.toLowerCase();
const match = title.includes(q);
d.style.display = match ? '' : 'none';
if (match) visible++;
});
noResults.style.display = visible ? 'none' : 'block';
});

document.getElementById('expandAll').addEventListener('click', () => {
items.forEach(d => { if (d.style.display !== 'none') d.open = true; });
});
document.getElementById('collapseAll').addEventListener('click', () => {
items.forEach(d => d.open = false);
});
</script>
</body>
</html>
"""


def render_command_artifacts_html(entries: list[dict], title: str) -> str:
"""Render command artifact entries into a self-contained HTML page.

Args:
entries: Records with command, stdout, stderr, and exit_code keys.
title: Label shown in the page header.

Returns:
str: Full HTML document.
"""
cards: list[str] = []
for entry in entries:
command = html.escape(str(entry.get("command", "") or ""))
stdout = str(entry.get("stdout", "") or "")
stderr = str(entry.get("stderr", "") or "")
exit_code = entry.get("exit_code", "")
badge_cls = "ok" if exit_code == 0 else "fail"

if stdout.strip():
stdout_block = "<pre>" + html.escape(stdout) + "</pre>"
else:
stdout_block = '<pre class="empty">(no stdout)</pre>'

if stderr.strip():
stderr_block = (
'<div class="stderr-label">stderr</div>'
'<pre class="stderr">' + html.escape(stderr) + "</pre>"
)
else:
stderr_block = ""

cards.append(
_CARD_TEMPLATE.format(
command=command,
badge_cls=badge_cls,
exit_code=html.escape(str(exit_code)),
stdout_block=stdout_block,
stderr_block=stderr_block,
)
)

return (
_HTML_HEAD
+ _HEADER_TEMPLATE.format(count=len(entries), title=html.escape(title))
+ "\n".join(cards)
+ _HTML_TAIL
)
10 changes: 10 additions & 0 deletions nodescraper/connection/inband/inband.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ class CommandArtifact(BaseModel):
stdout: str
stderr: str
exit_code: int
log_html: bool = False

def to_html_entry(self) -> dict:
"""Return a dict suitable for HTML command artifact rendering."""
return {
"command": self.command,
"stdout": self.stdout,
"stderr": self.stderr,
"exit_code": self.exit_code,
}


class BaseFileArtifact(BaseModel, abc.ABC):
Expand Down
34 changes: 29 additions & 5 deletions nodescraper/connection/inband/inbandmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from .inband import InBandConnection
from .inbandlocal import LocalShell
from .inbandremote import RemoteShell, SSHConnectionError
from .osdetection import NetworkOsDetection, detect_network_os
from .sshparams import SSHConnectionParams


Expand All @@ -68,6 +69,18 @@ def __init__(
**kwargs,
)

@staticmethod
def _apply_network_os_detection(
system_info: SystemInfo,
detection: NetworkOsDetection,
) -> None:
"""Apply network OS probe results to system info."""
system_info.os_family = detection.os_family
system_info.platform = detection.platform
if system_info.metadata is None:
system_info.metadata = {}
system_info.metadata.update(detection.metadata)

def _check_os_family(self):
"""Check the OS family of the system under test (SUT)

Expand All @@ -84,12 +97,23 @@ def _check_os_family(self):
elif res.exit_code == 0:
self.system_info.os_family = OSFamily.LINUX
else:
self._log_event(
category=EventCategory.UNKNOWN,
description="Unable to determine SUT OS",
priority=EventPriority.WARNING,
detection = detect_network_os(self.connection)
if detection is not None:
self._apply_network_os_detection(self.system_info, detection)
else:
self._log_event(
category=EventCategory.UNKNOWN,
description="Unable to determine SUT OS",
priority=EventPriority.WARNING,
)
if self.system_info.platform:
self.logger.info(
"OS Family: %s (%s)",
self.system_info.os_family.name,
self.system_info.platform,
)
self.logger.info("OS Family: %s", self.system_info.os_family.name)
else:
self.logger.info("OS Family: %s", self.system_info.os_family.name)

def connect(
self,
Expand Down
Loading
Loading