feat: add standalone server topology view
This commit is contained in:
+22
-3
@@ -488,7 +488,7 @@ func parsePCIeDeviceView(props map[string]string, result *models.AnalysisResult)
|
||||
DeviceID: deviceID,
|
||||
BDF: formatBDF(props["busnumber"], props["devicenumber"], props["functionnumber"]),
|
||||
Manufacturer: manufacturer,
|
||||
NUMANode: parseIntLoose(props["cpuaffinity"]),
|
||||
NUMANode: parseOptionalIntLoose(props["cpuaffinity"]),
|
||||
Status: normalizeStatus(props["primarystatus"]),
|
||||
}
|
||||
result.Hardware.PCIeDevices = append(result.Hardware.PCIeDevices, p)
|
||||
@@ -535,7 +535,7 @@ func parseNICView(props map[string]string, result *models.AnalysisResult) {
|
||||
props["controllerbiosversion"],
|
||||
)),
|
||||
PortCount: inferPortCountFromFQDD(fqdd),
|
||||
NUMANode: parseIntLoose(props["cpuaffinity"]),
|
||||
NUMANode: parseOptionalIntLoose(props["cpuaffinity"]),
|
||||
Status: normalizeStatus(props["primarystatus"]),
|
||||
}
|
||||
if mac != "" {
|
||||
@@ -589,7 +589,7 @@ func parseControllerView(props map[string]string, result *models.AnalysisResult)
|
||||
DeviceClass: "storage-controller",
|
||||
Manufacturer: strings.TrimSpace(firstNonEmpty(props["devicecardmanufacturer"], props["manufacturer"])),
|
||||
PartNumber: strings.TrimSpace(firstNonEmpty(props["ppid"], props["boardpartnumber"])),
|
||||
NUMANode: parseIntLoose(props["cpuaffinity"]),
|
||||
NUMANode: parseOptionalIntLoose(props["cpuaffinity"]),
|
||||
Status: normalizeStatus(props["primarystatus"]),
|
||||
})
|
||||
|
||||
@@ -983,6 +983,25 @@ func parseIntLoose(v string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// parseOptionalIntLoose preserves zero as a real value while keeping missing
|
||||
// and textual N/A values distinct. CPUAffinity is the motivating field: NUMA
|
||||
// node 0 is valid, whereas "Not Applicable" means there is no affinity.
|
||||
func parseOptionalIntLoose(v string) *int {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
num := firstNumberRE.FindString(v)
|
||||
if num == "" {
|
||||
return nil
|
||||
}
|
||||
n, err := strconv.Atoi(num)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &n
|
||||
}
|
||||
|
||||
func parseInt64Loose(v string) int64 {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
|
||||
+23
-12
@@ -390,7 +390,7 @@ func TestParseDellInfiniBandView(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestParseDellCPUAffinity verifies that CPUAffinity is parsed into NUMANode for
|
||||
// NIC, PCIe, and controller views. "Not Applicable" must result in NUMANode=0.
|
||||
// NIC, PCIe, and controller views. Zero is valid; "Not Applicable" is nil.
|
||||
func TestParseDellCPUAffinity(t *testing.T) {
|
||||
const viewXML = `<CIM><MESSAGE><SIMPLEREQ>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SystemView">
|
||||
@@ -439,27 +439,38 @@ func TestParseDellCPUAffinity(t *testing.T) {
|
||||
}
|
||||
|
||||
// NIC CPUAffinity=1 → NUMANode=1
|
||||
nicBySlot := make(map[string]int)
|
||||
nicBySlot := make(map[string]*int)
|
||||
for _, nic := range result.Hardware.NetworkAdapters {
|
||||
nicBySlot[nic.Slot] = nic.NUMANode
|
||||
}
|
||||
if nicBySlot["NIC.Slot.2-1-1"] != 1 {
|
||||
t.Errorf("NIC.Slot.2-1-1 NUMANode = %d, want 1", nicBySlot["NIC.Slot.2-1-1"])
|
||||
if got := nicBySlot["NIC.Slot.2-1-1"]; got == nil || *got != 1 {
|
||||
t.Errorf("NIC.Slot.2-1-1 NUMANode = %v, want 1", got)
|
||||
}
|
||||
if nicBySlot["InfiniBand.Slot.1-1"] != 2 {
|
||||
t.Errorf("InfiniBand.Slot.1-1 NUMANode = %d, want 2", nicBySlot["InfiniBand.Slot.1-1"])
|
||||
if got := nicBySlot["InfiniBand.Slot.1-1"]; got == nil || *got != 2 {
|
||||
t.Errorf("InfiniBand.Slot.1-1 NUMANode = %v, want 2", got)
|
||||
}
|
||||
|
||||
// PCIe device CPUAffinity=2 → NUMANode=2; controller CPUAffinity="Not Applicable" → NUMANode=0
|
||||
pcieBySlot := make(map[string]int)
|
||||
// PCIe device CPUAffinity=2 → NUMANode=2; controller CPUAffinity="Not Applicable" → nil.
|
||||
pcieBySlot := make(map[string]*int)
|
||||
for _, pcie := range result.Hardware.PCIeDevices {
|
||||
pcieBySlot[pcie.Slot] = pcie.NUMANode
|
||||
}
|
||||
if pcieBySlot["Slot.7-1"] != 2 {
|
||||
t.Errorf("Slot.7-1 NUMANode = %d, want 2", pcieBySlot["Slot.7-1"])
|
||||
if got := pcieBySlot["Slot.7-1"]; got == nil || *got != 2 {
|
||||
t.Errorf("Slot.7-1 NUMANode = %v, want 2", got)
|
||||
}
|
||||
if pcieBySlot["RAID.Slot.1-1"] != 0 {
|
||||
t.Errorf("RAID.Slot.1-1 NUMANode = %d, want 0 (Not Applicable)", pcieBySlot["RAID.Slot.1-1"])
|
||||
if got := pcieBySlot["RAID.Slot.1-1"]; got != nil {
|
||||
t.Errorf("RAID.Slot.1-1 NUMANode = %v, want nil (Not Applicable)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOptionalIntLoosePreservesZero(t *testing.T) {
|
||||
if got := parseOptionalIntLoose("0"); got == nil || *got != 0 {
|
||||
t.Fatalf("parseOptionalIntLoose(0) = %v, want pointer to 0", got)
|
||||
}
|
||||
for _, value := range []string{"", "N/A", "Not Applicable"} {
|
||||
if got := parseOptionalIntLoose(value); got != nil {
|
||||
t.Fatalf("parseOptionalIntLoose(%q) = %v, want nil", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+35
-1
@@ -11,7 +11,7 @@ import (
|
||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||
)
|
||||
|
||||
const parserVersion = "1.0"
|
||||
const parserVersion = "1.1"
|
||||
|
||||
func init() {
|
||||
parser.Register(&Parser{})
|
||||
@@ -126,6 +126,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
||||
PowerSupply: normalizePSUSlots(snapshot.Hardware.PowerSupply),
|
||||
},
|
||||
}
|
||||
result.TopologyEvidence = collectTopologyEvidence(files)
|
||||
|
||||
if pn := chassisProductPartNumber(files, result.Hardware.BoardInfo.ProductName); pn != "" {
|
||||
result.Hardware.BoardInfo.PartNumber = pn
|
||||
@@ -156,6 +157,39 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func collectTopologyEvidence(files []parser.ExtractedFile) *models.TopologyEvidence {
|
||||
read := func(names ...string) string {
|
||||
for _, name := range names {
|
||||
for _, f := range files {
|
||||
if strings.EqualFold(pathBase(f.Path), name) {
|
||||
return string(f.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
e := &models.TopologyEvidence{
|
||||
DIMMType17: read("dmidecode-type17.txt"),
|
||||
StorageMap: read("storage-controllers.txt"),
|
||||
NVIDIAQueryCSV: read("nvidia-smi-query-fresh.csv", "nvidia-smi-query.csv"),
|
||||
NVIDIATopology: read("nvidia-smi-topo-fresh.txt", "nvidia-smi-topo.txt"),
|
||||
NVLinkStatus: read("nvidia-smi-nvlink-status-fresh.txt", "nvidia-smi-nvlink-status.txt"),
|
||||
NVLinkErrors: read("nvidia-smi-nvlink-errors-fresh.txt", "nvidia-smi-nvlink-errors.txt"),
|
||||
}
|
||||
if e.DIMMType17 == "" && e.StorageMap == "" && e.NVIDIAQueryCSV == "" && e.NVIDIATopology == "" && e.NVLinkStatus == "" && e.NVLinkErrors == "" {
|
||||
return nil
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func pathBase(path string) string {
|
||||
path = strings.ReplaceAll(strings.TrimSpace(path), `\`, "/")
|
||||
if i := strings.LastIndexByte(path, '/'); i >= 0 {
|
||||
return path[i+1:]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
type beeSnapshot struct {
|
||||
SourceType string `json:"source_type,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
|
||||
+17
@@ -30,6 +30,23 @@ func TestDetectBeeSupportArchive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectTopologyEvidenceSupportsCategorizedBundleAndPrefersFresh(t *testing.T) {
|
||||
files := []parser.ExtractedFile{
|
||||
{Path: "bundle/export/memory/dmidecode-type17.txt", Content: []byte("memory")},
|
||||
{Path: "bundle/export/storage/storage-controllers.txt", Content: []byte("storage")},
|
||||
{Path: "bundle/export/gpu/nvidia-smi-query.csv", Content: []byte("query")},
|
||||
{Path: "bundle/export/gpu/nvidia-smi-topo.txt", Content: []byte("old")},
|
||||
{Path: "bundle/export/gpu/nvidia-smi-topo-fresh.txt", Content: []byte("fresh")},
|
||||
}
|
||||
got := collectTopologyEvidence(files)
|
||||
if got == nil {
|
||||
t.Fatal("expected topology evidence")
|
||||
}
|
||||
if got.DIMMType17 != "memory" || got.StorageMap != "storage" || got.NVIDIAQueryCSV != "query" || got.NVIDIATopology != "fresh" {
|
||||
t.Fatalf("unexpected topology evidence: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectBeeSupportArchiveV12Layout covers the real bundle layout produced
|
||||
// by bee-support v12.x, where bee-audit.json lives under tasks/_state/ and
|
||||
// runtime-health.json under status/, not export/. See CLAUDE.md memory on
|
||||
|
||||
+1
-1
@@ -952,7 +952,7 @@ func mergeInspurNIC(dst *models.NetworkAdapter, src models.NetworkAdapter) {
|
||||
if strings.TrimSpace(dst.MaxLinkSpeed) == "" {
|
||||
dst.MaxLinkSpeed = src.MaxLinkSpeed
|
||||
}
|
||||
if dst.NUMANode == 0 {
|
||||
if dst.NUMANode == nil {
|
||||
dst.NUMANode = src.NUMANode
|
||||
}
|
||||
if strings.TrimSpace(dst.Status) == "" {
|
||||
|
||||
Reference in New Issue
Block a user